From 55b629082560892e9768c6ff7c3f5263f77744e6 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 8 Aug 2026 09:57:45 +0000 Subject: [PATCH 1/2] Stage 2 ran against a real daemon, and both live tests pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first execution of any part of this repository against a real Docker daemon. On Node B, in the deployed stack, against a dedicated Docker-in-Docker daemon beside the controller: success: True .. [100%] An agent reads and writes its own worktree, reads a declared mount, cannot reach an undeclared sibling path that exists on the host, receives none of the controller's credentials while still receiving explicitly passed variables, is denied the network under `network=none` and allowed it under `bridge`, and its container is gone after teardown. The evidence package records the image digests, the Komodo update id and the command to run it again, because "it passed" is not reproducible and a tag moves. **The Stage 2 exit is not claimed.** The security profile itself — no-new-privileges, dropped capabilities, read-only rootfs, resource limits — is asserted only in the `docker create` argv and never verified from inside a live container. The nested daemon's own confinement is untested. One sandbox image has been used, and the last defect this suite found was an assumption about exactly that. No workload ran. Also here: the build now pins and bumps the acceptance image by sha. It pinned `:latest`, and `docker compose run` does not re-pull a tag it already holds -- so the live suite silently re-ran a stale image and reproduced a defect that had already been fixed and deployed. An acceptance run that cannot name the image it ran is not evidence. --- .github/workflows/build-deploy.yml | 12 ++ docs/STATUS.md | 40 ++++-- ...6-08-08-stage-2-live-execution-boundary.md | 114 ++++++++++++++++++ 3 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 docs/evidence/2026-08-08-stage-2-live-execution-boundary.md diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index ba74ca4..8e12505 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -81,6 +81,7 @@ jobs: STACK_NAME: ${{ env.STACK_NAME }} STACK_DIR: ${{ env.STACK_DIR }} IMAGE_NAME: ${{ env.IMAGE_NAME }} + TEST_IMAGE_NAME: ${{ env.TEST_IMAGE_NAME }} IMAGE_TAG: ${{ github.sha }} GIT_AUTH_TOKEN: ${{ secrets.GIT_AUTH_TOKEN }} KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }} @@ -94,4 +95,15 @@ jobs: -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ "https://repo.indexarr.net/api/v1/repos/indexarr/ops/raw/scripts/komodo-deploy.sh" \ -o komodo-deploy.sh + + # The acceptance image first, then the runtime image. + # + # Both are pinned to this commit's sha, and that is not tidiness. The + # acceptance service used to pin `:latest`, and `docker compose run` + # does not re-pull a tag it already holds -- so the Stage 2 live suite + # silently executed a STALE image and reproduced a defect that had + # already been fixed and deployed. Evidence from an image nobody can + # name is not evidence. The script only rewrites `image: $IMAGE_NAME:*` + # lines, so each call touches exactly its own service. + IMAGE_NAME="${TEST_IMAGE_NAME}" bash komodo-deploy.sh bash komodo-deploy.sh diff --git a/docs/STATUS.md b/docs/STATUS.md index 5d331f4..4fa038d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -229,17 +229,36 @@ the currently measured delivery failure. Model-call events carry project, item and work-attempt identity; item budgets and terminal policy refusals stop at loop boundaries. Evidence is in [`evidence/2026-08-06-stage-1-role-runner.md`](evidence/2026-08-06-stage-1-role-runner.md). -- **Stage 2 is in implementation; its exit is pending.** The generic - execution-environment contract, metadata-selected Docker backend, disposable - per-item self-contained Git checkout, explicit image/network/mount configuration, - controller-environment allow-list, pre-claim readiness check and teardown path - are implemented and covered by local contract tests. The current host has - Docker CLI but no reachable daemon, so the required tests against the actual - backend — repository-wide work, undeclared sibling/host refusal, declared - mount modes, allowed network access and clean teardown — have not yet run. No - real workload run is authorised by the Stage 2 implementation evidence. See - +- **Stage 2 has run against a real daemon; its exit is still not claimed.** + The generic execution-environment contract, metadata-selected Docker backend, + disposable per-item self-contained Git checkout, explicit image/network/mount + configuration, controller-environment allow-list, pre-claim readiness check + and teardown path are implemented and covered by local contract tests. See [`evidence/2026-08-06-stage-2-execution-environment.md`](evidence/2026-08-06-stage-2-execution-environment.md). + + On 2026-08-08 the live suite ran on Node B against a dedicated Docker-in- + Docker daemon deployed beside the controller, and passed: an agent reads and + writes its own worktree, reads a declared mount, cannot reach an undeclared + sibling path, does not receive controller credentials, is denied the network + under `network=none` and allowed it under `bridge`, and its container is gone + after teardown. Image digests, the update id and the reproduction command are + in + [`evidence/2026-08-08-stage-2-live-execution-boundary.md`](evidence/2026-08-08-stage-2-live-execution-boundary.md). + + **That is a substantial part of the exit, not the whole of it.** §2.7 also + requires the security profile itself — `no-new-privileges`, dropped + capabilities, a read-only root filesystem, resource limits and the recorded + image digest — and those are asserted only in the `docker create` argv, never + verified from inside a live container. The nested daemon's own confinement is + untested. Only one sandbox image has been used (Alpine/BusyBox), and the last + defect this suite found was an assumption about exactly that. **No real + workload run is authorised.** + + The live run earned its cost. Five defects were found that local runs and CI + could not see, the sharpest being that every sandbox command failed against + BusyBox because the harness wrapped them in GNU-only `timeout --signal=TERM` + — a failure that reads as the agent failing rather than the harness, which is + the misattribution class of #216. - **Stage 3 wiring is present but its exit is not claimed.** `serve` can now construct an AIDevEnv-independent local fleet from the metadata-selected role runner and execution backend; readiness and preflight use that executor @@ -788,6 +807,7 @@ evidence and do not build on them. | One plan branch yields exactly one pull request: a correction updates it, an unchanged head touches no remote, an existing PR is adopted, a foreign push is refused, and nothing is merged | **tested** | `tests/test_plan_publication.py` — against a local bare remote and a fake pull-request client, never GitHub | | A fleet publishes that one pull request only once the plan has stopped moving, pushes no item branch, and updates the same PR for a later correction | **tested** | `tests/test_plan_integration.py::test_fleet_publishes_one_plan_pr_only_when_the_plan_is_finished` | | An installed review source gives reviews and review comments distinct immutable identities and decides disposition without a model, defaulting unmarked prose to a hold | **tested** | `tests/test_github_pr_review_source.py` — `gh` is injected; no real pull request has been polled | +| An agent reads and writes its own worktree, reads a declared mount, cannot reach an undeclared sibling path, receives no controller credentials, is denied the network under `none` and allowed it under `bridge`, and its container is removed on teardown — against a real Docker daemon | **observed** | [`evidence/2026-08-08-stage-2-live-execution-boundary.md`](evidence/2026-08-08-stage-2-live-execution-boundary.md) — one run, one host, one sandbox image, with the image digests and update id recorded. The security profile itself is argv-tested only. | | The service runs and is deployed inside AIDevEnv | **observed** | no preserved artefacts | | An earlier supervised NGMS attempt and later direct calls exercised real agents and providers | **observed** | [`evidence/2026-08-03-04-ngms-first-sustained-run-v1.md`](evidence/2026-08-03-04-ngms-first-sustained-run-v1.md) — lacks a common run ID, complete configuration, checksums and a comparable follow-up | | Four executor passes against rdpapp delivered nothing, and why each failed | **observed** | [`evidence/2026-08-05-06-rdpapp-m2-status.md`](evidence/2026-08-05-06-rdpapp-m2-status.md); the pass 3–4 attribution is hindsight and has not been confirmed by re-running against the fix | diff --git a/docs/evidence/2026-08-08-stage-2-live-execution-boundary.md b/docs/evidence/2026-08-08-stage-2-live-execution-boundary.md new file mode 100644 index 0000000..1ab9d69 --- /dev/null +++ b/docs/evidence/2026-08-08-stage-2-live-execution-boundary.md @@ -0,0 +1,114 @@ +# Stage 2 live evidence — the execution boundary, against a real daemon + +**Date:** 2026-08-08 +**Where:** Node B (`winrarhost`), Komodo stack `personal-agent-harness`, against +a dedicated Docker-in-Docker daemon deployed beside the controller. +**Scope:** the two tests in `tests/test_execution_environment_live.py`, run in +the deployed stack. No model was called, no workload was run, and no remote +repository was contacted. + +This is the first time any part of this repository has executed against a real +Docker daemon. Everything before it was a mocked subprocess. + +**It is not the Stage 2 exit.** §"What this does not cover" below says what is +still missing, and that list is not short. + +## What ran, and how to run it again + +```bash +# Komodo: POST /execute/RunStackService, then poll /read/GetUpdate. +{"stack": "personal-agent-harness", "service": "agent-harness-tests"} +``` + +```console +success: True +.. [100%] +``` + +Update `6a76fcfbe1e8f310d4eae2c2`. Two tests, two passes, zero skips — the +suite skips itself unless `HARNESS_STAGE2_IMAGE` names a pullable image and a +daemon answers, so a pass cannot be a silent no-op. + +### The exact artefacts + +Recorded because "it passed" is not reproducible and a tag moves: + +| artefact | identity | +|---|---| +| controller image | `agent-harness:75e76ed5ad5e70d94b68e55c4582cb9efb2f1120` → `sha256:65a4f2073df81572f4e8c4100d89af425dd4e47450f883918471d94ba8d7b527` | +| acceptance image | `agent-harness-test:75e76ed5ad5e70d94b68e55c4582cb9efb2f1120` → `sha256:9808728bd93bf33417ed3151840f3744198c9c3fca22bfb64ab34b640eb652fa` | +| item sandbox image | `alpine:3.21` → `sha256:2607caa9805847fac4de202017bb1b830deb09f4c07dc9964a0157abbc604577` | +| nested daemon | `docker:28-dind` → `sha256:6a68f64cf32d98b09a11c208de78f59f17c0a6fff33c13f11acac853d6aad5ae` | + +## What the two tests prove + +Each row is an assertion that fails if the property stops holding, executed +against the daemon named above. + +| criterion | result | how | +|---|---|---| +| An agent can **read** its own worktree | pass | `cat /workspace/inside.txt` returned `inside`, from a file the controller wrote outside the container | +| An agent can **write** its own worktree | pass | `printf changed > /workspace/result.txt`, then read back by the controller | +| A **declared** dependency mount is readable | pass | `test -f /opt/dependency/readme.txt` | +| An **undeclared** host/sibling path is unreachable | pass | `test ! -e "$HOST_SIBLING"`, where the path exists on the host and holds content | +| Controller **credentials** do not enter the agent environment | pass | `HARNESS_STAGE2_CONTROLLER_SECRET` is set in the controller process and absent in the container | +| An explicitly passed variable **does** arrive | pass | `DECLARED=yes`, so the credential result above is not merely an empty environment | +| `network=none` **denies** outbound access | pass | `wget https://example.com` → `bad address` (DNS and egress both fail) | +| `network=bridge` **allows** outbound access | pass | the same fetch succeeds — P6's "internet is available" is real, not aspirational | +| The container is **gone** after teardown | pass | `docker inspect ` fails after `close()` | + +## What this does not cover + +Named explicitly, because a green run invites over-reading. + +- **The security profile is asserted only in argv.** `--read-only`, + `--cap-drop ALL`, `--security-opt`, the resource limits and the resolved + image digest are covered by `tests/test_execution_environment.py` at the + `docker create` command line, and are **not** verified from inside a live + container. Nothing here proves the kernel applied them. +- **The nested daemon's own confinement is untested.** It is `privileged`, on + an internal network with nothing published, and no test asserts either + property. +- **One image, one shape.** Alpine/BusyBox only. A workload toolchain image — + Rust, for the first real workload — has never been used, and the last defect + this suite found was precisely an assumption about which userland is present. +- **No workload ran.** No model call, no item, no plan. Stage 2 says a real + workload is not authorised until its exit is met, and it is not met. +- **The controller has no model routes**, so the deployed service cannot claim + work at all. That is deliberate for a first deployment, and it means nothing + here exercises the fleet. + +## What the live run found that nothing else did + +Recorded because it is the argument for having deployed at all. Each of these +was invisible to local runs and to CI, and each is fixed: + +| defect | why only a real daemon found it | +|---|---| +| `timeout --signal=TERM 30s` is GNU-only | against BusyBox **every** sandbox command returned 1 with `timeout: unrecognized option`, which reads as the agent failing rather than the harness's wrapper being unportable — the misattribution class of #216 | +| two divergent copies of the daemon check | `DockerItemEnvironment.check()` kept the `--format` template defect after the factory's copy was fixed; an unreachable daemon reported a Go reflect error instead of naming the daemon | +| `serve` exited 2 with a fleet and no routes | the API and GUI never came up to say why, and the supervisor restarted it every 60 seconds | +| mounts owned by root, controller uid 1000 | a mount replaces the image's directory, so the image's `chown` is not the runtime truth; the controller crash-looped on `unable to open database file` | +| the acceptance pinned `:latest` | `docker compose run` does not re-pull a tag it already holds, so the suite silently re-ran a **stale** image and reproduced an already-fixed defect. `agent-harness-test:latest` was `sha256:d73e54bd…` while the current build was `sha256:9808728b…` | + +The last one is worth keeping in mind when reading any evidence produced this +way: an acceptance run that cannot name the image it ran is not evidence. Both +images are now pinned to a commit sha and the build bumps both. + +## The uid that made the difference + +The confinement test first failed with + +``` +/bin/sh: can't create /workspace/result.txt: Permission denied +``` + +That is not a boundary failure. The acceptance container ran as root, so its +fixture worktrees were root-owned, and the item container — uid 1000, which is +`EnvironmentSpec.user`'s default and the uid the deployed controller runs as — +could not write to its own checkout. + +The fix was to make the acceptance mirror the deployment (`user: "1000:1000"`), +not to relax the assertion. An agent being able to write its worktree is one of +the things Stage 2 exists to prove, and weakening it to get a green run would +have produced exactly the kind of evidence this repository refuses to accept. From 3d4172028e8754115f6273c85069287bef067363 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 17 Aug 2026 01:19:09 +0000 Subject: [PATCH 2/2] Set the minimal local product contract and admission preview/apply path Adds minimal.md as the authoritative product-scope contract, BACKLOG.md as the exploration-complete implementation backlog, and nextsteps.md's supersession of the old Rainmon-specific runbook. Implements the read-only admission preview/apply service, the versioned embedded-TOML plan contract, immutable plan revisions, and exploration defaults, wired into the API with new /api/plans/admission/preview and /apply endpoints and covered by new test suites for each module. --- AGENTS.md | 4 +- BACKLOG.md | 2050 +++++++++++++++++++ GUI_PLAN.md | 13 +- README.md | 73 +- docs/AUDIT-PLAN.md | 1 + docs/COORDINATION-PLANE.md | 5 +- docs/DESIGN.md | 18 +- docs/E2E-ACCEPTANCE-PLAN.md | 161 ++ docs/FIT-FOR-PURPOSE-STATUS.md | 1 + docs/HARNESS-PLAN.md | 1 + docs/MULTI-PROJECT-PLAN.md | 1 + docs/PROPOSAL-2026-08-agent-interaction.md | 1 + docs/PROPOSAL-2026-08-finish-then-extend.md | 1 + docs/PROPOSAL-2026-08-fit-for-purpose.md | 1 + docs/STATUS.md | 1088 +++------- docs/USAGE.md | 53 +- examples/PLAN.md | 199 +- minimal.md | 457 +++++ nextsteps.md | 39 + src/agent_harness/__main__.py | 132 +- src/agent_harness/admission_service.py | 510 +++++ src/agent_harness/api.py | 165 ++ src/agent_harness/exploration_defaults.py | 161 ++ src/agent_harness/graph.py | 8 +- src/agent_harness/inception.py | 14 +- src/agent_harness/plan_contract.py | 583 ++++++ src/agent_harness/plan_revisions.py | 182 ++ src/agent_harness/plan_validation.py | 296 +++ src/agent_harness/schemas.py | 106 + src/agent_harness/survey.py | 9 +- src/agent_harness/work.py | 104 +- tests/test_admission_apply.py | 222 ++ tests/test_admission_service.py | 77 + tests/test_api.py | 81 + tests/test_inception.py | 20 +- tests/test_plan_contract.py | 153 ++ tests/test_plan_revisions.py | 108 + tests/test_plan_validation.py | 58 + tests/test_survey.py | 11 + 39 files changed, 6268 insertions(+), 899 deletions(-) create mode 100644 BACKLOG.md create mode 100644 docs/E2E-ACCEPTANCE-PLAN.md create mode 100644 minimal.md create mode 100644 nextsteps.md create mode 100644 src/agent_harness/admission_service.py create mode 100644 src/agent_harness/exploration_defaults.py create mode 100644 src/agent_harness/plan_contract.py create mode 100644 src/agent_harness/plan_revisions.py create mode 100644 src/agent_harness/plan_validation.py create mode 100644 tests/test_admission_apply.py create mode 100644 tests/test_admission_service.py create mode 100644 tests/test_plan_contract.py create mode 100644 tests/test_plan_revisions.py create mode 100644 tests/test_plan_validation.py diff --git a/AGENTS.md b/AGENTS.md index 6aaf33b..68a68f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,8 @@ trade and it is rejected. | Thing | Location | |---|---| +| **Minimum generic product target and plan template** | `minimal.md` — local-only lifecycle and its acceptance contract | +| **Exploration-complete implementation backlog** | `BACKLOG.md` — dependencies, decisions, tests, and evidence for every target gap | | How to use it | `docs/USAGE.md` — worked example, real output | | **Starting a project from nothing** | `docs/USAGE.md` §0 — the four routes in, and which to pick | | **Taking on a project already in flight** | `agent-harness adopt`; `src/agent_harness/adoption.py`. **A proposal is never a decision** — nothing is dropped unless a human names it | @@ -71,7 +73,7 @@ trade and it is rejected. | What is configured and what is missing | `agent-harness doctor`; `src/agent_harness/doctor.py` — reports, spends nothing | | How to deploy it | `docs/DEPLOYMENT.md` — the two serve modes, and a non-destructive smoke test | | Sample plan | `examples/PLAN.md` | -| The original plan | `docs/HARNESS-PLAN.md` (superseded in part) | +| The original plan | `docs/HARNESS-PLAN.md` (historical and superseded) | | Issue tracker | GitHub, per D1. The only place an issue's *state* lives. | | The manifest that seeded those issues | `docs/backlog-seed-2026-08-02.json` — historical, carries no state, not kept in sync | | What a route is made of, and how a vendor is added | `src/agent_harness/protocols.py` | diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..5b02b59 --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,2050 @@ +# Minimal local product implementation backlog + +**Prepared:** 2026-08-09 + +**Target:** [`minimal.md`](minimal.md) + +**Current state:** [`docs/STATUS.md`](docs/STATUS.md) + +**Authoring example:** [`examples/PLAN.md`](examples/PLAN.md) + +The repository has no root `plan.md`; the review therefore treats +`examples/PLAN.md`—the sample named by `AGENTS.md`—as the requested plan +document. + +This is the implementation backlog for closing the gap between the current +pre-alpha repository and the minimal generic local product. It replaces the +short sequence in [`nextsteps.md`](nextsteps.md) with work items that are ready +for implementation. + +“Exploration complete” has a precise meaning here: the product decision, +boundary, current footing, expected interfaces, failure behaviour, tests, and +exit evidence are stated for every item. It does **not** mean the implementation +or live evidence exists. Items that require a real daemon, model, or application +are marked evidence-bound; their software prerequisites are still explicit. + +## 1. Review findings + +The three reviewed documents agree on the important direction: + +- the product is generic and local-only at its minimum boundary; +- a user writes a complete plan from a template; +- deterministic validation rejects an incomplete plan in one response; +- optional questions happen only after minimum validation and are returned as + one batch; +- toolchains, dependencies, services, mounts, network, and local delivery are + project declarations, not Rainmon or harness-core assumptions; +- accepted work is integrated through local Git, then built, deployed, checked, + and cleaned up locally; +- remote issues, branches, pull requests, hosted CI/CD, and non-local deployment + are extensions, not prerequisites. + +The review also found four details that needed settling before implementation: + +1. [`minimal.md`](minimal.md) showed an illustrative manifest but did not yet + select its exact authoring syntax. +2. [`examples/PLAN.md`](examples/PLAN.md) deliberately omitted that manifest + because the validator does not exist; it is compatible with the current item + parser, but is not a valid target-plan fixture yet. +3. The current Docker image and mount settings are deployment-wide. Admission + cannot honestly claim a project environment until those values are versioned + with the project plan. +4. The repository has local integration readiness hidden inside remote + publication code, while it has no product-under-development delivery journal + or topology owner. + +The decisions below close those exploration gaps. + +## 2. Settled cross-cutting decisions + +These decisions apply to every backlog item and are not reopened inside an +implementation item. + +### 2.1 Plan format + +- A target plan is Markdown containing **exactly one** fenced `harness` block. +- The block contains TOML. Python 3.12's standard-library `tomllib` parses it; + core gains no YAML dependency. +- `version = 1` is mandatory. Unknown versions and unknown fields are errors, + not ignored future configuration. +- Prose remains authoritative for human intent. The TOML manifest is + authoritative for values the harness executes or persists as configuration. +- Work items remain Markdown headings parsed by the existing item parser. The + manifest does not duplicate item titles, briefs, or dependencies. +- Every executable command is a non-empty array of non-empty strings. No shell + fragment is inferred from prose. + +### 2.2 Required Markdown shape + +The exact level-two headings from `minimal.md` are required once each, +case-insensitively after whitespace normalisation. Order is recommended but not +load-bearing. Duplicate required headings are errors. + +Each work item uses the existing `### W1: Title` family of headings and must +contain: + +- `deliverable: code` or `deliverable: findings` using the existing metadata; +- a non-empty `**Deliverable:**` observable outcome; +- a non-empty `**Acceptance:**` list; +- explicit dependency intent through `depends on: none`, one or more existing + dependency tokens, or a fenced `dependencies` graph plus `depends on: none` + for items with no incoming edge. The v1 parser treats the exact token `none` + as the empty set; omission is an error because “independent” and “forgot to + state dependencies” are not distinguishable. + +For the local minimum, required dependency targets may be local work, a human +decision retained by the harness, or another already admitted local harness +project/revision. A required `external:` resolver target would make remote or +third-party state a prerequisite, so target-mode admission rejects it. Advisory +external references remain evidence only; optional remote workflow modes may +continue to resolve required external dependencies outside this contract. + +`Not applicable: ` is the only way to satisfy a required prose section +or cross-cutting field that does not apply. An empty reason is an error. + +### 2.3 Version-one manifest boundary + +The version-one manifest owns these typed categories: + +```toml +version = 1 + +[project] +key = "widgets" +name = "Widget service" + +[repository] +base_ref = "main" +integration_ref = "harness/widgets" +# finalise_ref = "main" # omit unless a reviewed local fast-forward is wanted + +[agents] +role_runner = "agent-loop" +required_roles = ["implementer", "reviewer"] +max_workers = 2 +max_attempts = 5 +max_item_seconds = 3600 +max_item_spend_usd = 0.0 # zero means unlimited, preserving the current default +max_hold_seconds = 21600 + +[execution] +backend = "docker" +network = "none" # none | project +toolchains = ["Python 3.13"] +system_packages = [] + +[execution.image] +strategy = "existing" # existing | build +reference = "example/runner@sha256:" +# pull = false +# strategy = "build" instead uses source + context below +# source = "containerfile" # containerfile | generated +# dockerfile = ".harness/Containerfile" +# context = "." +# build_network = "none" # none | egress; egress is reviewed preparation only +# A generated source uses immutable_base, workdir, copy, setup, and user: +# immutable_base = "example/base@sha256:" +# workdir = "/workspace" +# copy = ["pyproject.toml", "uv.lock"] +# setup = [["uv", "sync", "--frozen"]] +# user = "1000:1000" + +[execution.limits] +command_timeout_seconds = 300 +memory = "2g" +cpus = "2" +pids = 512 +user = "1000:1000" +rootfs_read_only = true +tmpfs_size = "512m" + +[[execution.mounts]] +source = "./.cache" +target = "/opt/project-cache" +writable = false + +[[execution.probes]] +name = "python" +command = ["python", "--version"] +expect_regex = "^Python 3\\.13" + +[[execution.secrets]] +name = "PRIVATE_INDEX_TOKEN" +source = "environment" +scope = "provisioning" # provisioning | checks | delivery + +[execution.config] +# Backend-specific, validated by the explicitly selected installed backend. + +[checks] +item = [["python", "-m", "pytest", "-q"]] +integration = [["python", "-m", "pytest", "-q"]] + +[local_delivery] +backend = "local-process" +build = [["python", "-m", "build"]] +build_context = "runner" # runner | host +readiness = [["python", "scripts/readiness.py"]] +readiness_context = "host" # host | runner +acceptance = [["python", "-m", "pytest", "-q", "acceptance"]] +acceptance_context = "host" # host | runner +required_host_tools = ["python"] +command_timeout_seconds = 900 +readiness_timeout_seconds = 120 +readiness_interval_seconds = 2 +teardown_timeout_seconds = 30 + +[local_delivery.config] +# Backend-specific start and ownership values. +``` + +Settled interpretations: + +- The repository worktree path is supplied at admission. It is deliberately + not embedded in a reusable plan. +- `base_ref` and `integration_ref` are local refs. Validation uses + `git check-ref-format`; admission resolves the exact base SHA without fetch. +- The admitted base SHA is immutable for that revision. Movement of `base_ref` + after admission neither rebases nor refreshes work silently. A later plan + revision may bind a new base SHA and explicitly invoke the existing durable + replay/re-gate machinery before work resumes. +- `integration_ref` is a stable project-owned local working ref and may not be + the base/finalisation ref or collide with an unowned ref. Item refs are + derived under a harness-owned project/revision namespace, so identical item + IDs in two projects sharing a repository cannot overwrite each other. +- Optional `finalise_ref` merely enables the separately reviewed fast-forward + action in L5. Omitting it leaves the accepted integration ref as the product. +- `toolchains` and `system_packages` are declarations for review. `probes` are + the generic executable proof that the resulting environment has them. +- `execution.config` and `local_delivery.config` are opaque to core but not + unvalidated: the selected installed adapter validates them and returns the + same diagnostic type as core. +- `none` and `project` are the only v1 item-runtime network policies. + `project` means the isolated internal network owned by the execution backend + for the revision's supporting services. Unrestricted bridge/host networking + is not silently translated into either. +- Model transport stays in the controller. A runner container does not receive + model credentials merely because agents use a remote model. +- Authoritative item and integration checks execute in the pinned execution + environment, against the same candidate tree the implementer changed. Final + delivery build/readiness/acceptance arrays each declare `runner` or `host` + context. Runner context reuses E6 at the detached accepted tree. Host context + uses direct argv on the harness host and must name its programs in + `required_host_tools`; preflight proves them. Delivery adapters do not choose + or silently change a command's context. +- The image strategy accepts an immutable local image or builds a reviewed + repository-owned Containerfile. Core does not invent package-manager commands + from toolchain prose. +- `deploy` and `teardown` are not arbitrary core command lists. The selected + delivery adapter owns resource creation, identity, inspection, recovery, and + cleanup. Build/readiness/acceptance remain guarded argv commands. + +### 2.4 Admission and revision rules + +- Validation is deterministic, model-free, read-only, and returns all findings. +- Optional semantic review is a separate, explicit, spend-bearing action after + deterministic success. It uses a new `plan_reviewer` role and never changes + the item-review prompt; D9 therefore remains untouched. +- Questions are returned as one report. There is no question-by-question plan + completion loop. Blocking questions require a revised plan; advisory ones are + retained with the admitted revision. +- Apply is bound to the exact plan-byte digest, exact local repository identity, + exact base SHA, effective adapter versions, and preview digest. +- Project, immutable plan revision, current-plan pointer, work-item projection, + and dependency graph change in one `BEGIN IMMEDIATE` SQLite transaction. +- Reapplying the same accepted digest is an idempotent no-op. +- Omitting a previously admitted item is not permission to drop it. A revision + must explicitly name removed item IDs and a reason in its admission request. + Active/held items prevent revision; completed items remain historical; a + never-started removed item becomes inactive with a recorded reason. +- Admission never starts workers. Every admitted project is stopped. + +### 2.5 Execution and delivery boundaries + +- Effective execution configuration is persisted per plan revision. Global + settings may seed a preview but are never the hidden source of truth. +- Model route credentials and endpoints remain deployment/operator + configuration. A revision binds route requirements and the exact effective + route snapshot used for preview/run evidence; it does not put credential + values or necessarily portable endpoints in a reusable plan. +- Runner preparation produces an immutable image digest/profile digest before + a worker can claim. +- Profile/image/service preparation is explicit local operator infrastructure + work and does not count as project build/deploy. The no-CI/CD boundary means + the harness neither invokes hosted pipelines nor hides these local effects + behind one; it does not mean a useful runner must already exist by magic. +- Item execution continues through the existing attempt, check, checkpoint, + reviewer, and plan-promotion gates. Those gates are not weakened or copied. +- Whole-plan delivery is a separate fixed lifecycle with its own durable + journal. It does not add stages to `attempts.STAGES` and is not a workflow + engine. +- Fixed delivery stages are `prepared`, `built`, `deployed`, `ready`, + `accepted`, and `torn_down`. Recovery is an outcome on an interrupted run, + not a user-registerable stage. +- Delivery occurs from a disposable detached worktree at the exact accepted + integration SHA, never from the developer's checkout. +- Delivery starts only through an explicit reviewed action after local plan + readiness. Admission authorises the declared commands; the delivery action + confirms the exact accepted SHA and effective profile that will run. +- Teardown is attempted after every successful deploy, including readiness or + acceptance failure. A teardown failure cannot turn failed acceptance into + success or erase the earlier answer. +- A successful delivery marks the integration SHA locally accepted. An + optional, separately reviewed finalisation may fast-forward a named local ref + with compare-and-swap. No operation pushes. + +### 2.6 Extension and evidence rules + +- Execution and delivery adapters are selected by name through installed + metadata. Core does not import or contain dotted paths to adapters. +- Delivery adapters implement resource ownership; they do not register new + gate types or outcomes. D8 remains open and irrelevant to this backlog. +- New core modules on admission/execution/delivery paths are added to + `tests/test_generic.py::EXECUTION_PATH` in the same change that introduces + them. +- Every durable free-text payload passes existing redaction before its first + write. A plan that contains a known or credential-shaped value is rejected + before its exact text is persisted; secret names alone are valid. +- Large command output is a bounded, checksummed artifact. Events retain the + outcome, command, bounds, checksum, size, and artifact reference rather than + unbounded output. + +## 3. Milestones and dependency spine + +| Milestone | Outcome | Backlog items | +|---|---|---| +| M1 | A complete plan can be validated without a conversation. | P1, E0, L1–L2, P2–P4 | +| M2 | A valid plan can be reviewed and admitted atomically. | A1–A5 | +| M3 | Each admitted revision has a proved, pinned execution environment. | E1–E6 | +| M4 | The existing item and integration path is wholly local and live-proven. | I1–I3 | +| M5 | An accepted integration commit can be delivered and cleaned up locally. | L2–L6 | +| M6 | Evidence and two-project proof satisfy `minimal.md`. | V1–V5 | + +The critical path is: + +```text +P1 -> E0/L1 -> P2 -> P3 -> A2 -> A3 -> E1 -> E3 -> E5 -> E6 -> I3 + \ \ + A1 -------------------+-> L4 -> L5 -> V2 -> V3 -> V4 -> V5 + E4 -> L2/L3 / +``` + +The complete machine-readable edge list, using the current parser's arrow +direction (“left is prerequisite; right waits”), is: + +```dependencies +P1 -> E0 +P1 -> L1 +P1 -> P2 +E0 -> P2 +L1 -> P2 +P2 -> P3 +P2 -> P4 +L2 -> P4 +P1 -> A1 +P2 -> A2 +P3 -> A2 +A1 -> A3 +A2 -> A3 +A3 -> A4 +P3 -> A5 +A3 -> A5 +A4 -> A5 +A1 -> E1 +P1 -> E1 +E0 -> E1 +E1 -> E2 +A5 -> E2 +E1 -> E3 +E3 -> E2 +E1 -> E4 +E3 -> E4 +E0 -> E4 +E2 -> E5 +E3 -> E5 +E4 -> E5 +A5 -> E5 +E1 -> E6 +E5 -> E6 +A5 -> I1 +E6 -> I1 +A4 -> I2 +I1 -> I2 +I1 -> I3 +I2 -> I3 +E6 -> I3 +L1 -> L2 +L1 -> L3 +E3 -> L3 +I2 -> L4 +E6 -> L4 +L1 -> L4 +E3 -> L4 +L4 -> L5 +L4 -> L6 +L5 -> L6 +P3 -> V1 +A5 -> V1 +E6 -> V1 +I2 -> V1 +L6 -> V1 +I3 -> V2 +L5 -> V2 +L6 -> V2 +V1 -> V2 +L2 -> V2 +V2 -> V3 +L3 -> V3 +V2 -> V4 +V3 -> V4 +V1 -> V5 +V2 -> V5 +V3 -> V5 +V4 -> V5 +``` + +## 4. Plan contract and validation + +### P1: Implement the versioned plan-contract domain model + +**State:** ready; exploration complete. + +**Outcome:** Core has typed, immutable Python models for the v1 manifest, +required prose sections, item completion fields, commands, mounts, probes, +secret references, execution profile, and local delivery profile. + +**Current footing:** `plan.py` owns item parsing and dependency diagnostics; +`schemas.py::ProjectSpec` owns a smaller runtime configuration; TOML is not +parsed. Commands currently cross several boundaries as shell-like strings and +are split later. The current parser treats `depends on: none` as a dependency +named `none`; v1 must special-case the exact token as an explicit empty set. + +The manifest also separates reusable repository intent from operator-owned +model routing. `[agents].required_roles` and `role_runner` say which +capabilities a project needs. Endpoint URLs, model IDs, route presets, fallback +chains, price metadata, and credential values stay in the deployment/project +route map already owned by `ProjectSpec.roles` and the controller. Admission +resolves and binds their safe effective snapshot; the plan never embeds a key. + +**Implementation decision:** Add `plan_contract.py`. It parses only the fenced +manifest and required section structure, then composes the existing +`ParsedPlan`; it does not replace `plan.py`. Use frozen dataclasses internally +and Pydantic response models only at the API boundary. Preserve declared +commands as `tuple[str, ...]` from parse onward. The agent role runner may still +accept screened shell text as its interactive tool protocol; that does not +permit a plan/check/lifecycle command to be converted back into shell text. +Reject unknown keys at every typed level. Pass `execution.config` and +`local_delivery.config` as immutable JSON-compatible mappings after `tomllib` +conversion. + +**Primary surfaces:** `src/agent_harness/plan_contract.py`, `plan.py`, +`schemas.py`, `tests/test_plan_contract.py`, `tests/test_generic.py`. + +**Required tests:** one complete parse; every missing/duplicate section; +multiple/no manifest fences; bad TOML with line/column; unknown version/key; +empty argv element; unsafe/relative mount target; invalid secret scope; invalid +ref spelling; `N/A` with and without reason; code/findings items; manifest +round-trip to a canonical JSON representation; declared commands stay argv at +every typed boundary; exact `depends on: none` becomes no edge while omission +is rejected. Tests use no model, network, or filesystem outside a temporary +plan. + +**Acceptance evidence:** the example fixture plus a second materially different +fixture parse to typed objects; no adapter is imported during core parsing; the +old item-parser tests remain green. + +**Depends on:** none. + +**Non-goals:** executing commands, resolving a Git ref, selecting a model, +validating backend-specific config, or deciding whether prose architecture is +good. + +### P2: Build the aggregate deterministic validator + +**State:** ready after P1, E0, and L1; exploration complete. + +**Outcome:** One validation run reports every independently discoverable plan +problem and returns non-zero without writing anything. + +**Current footing:** `plan_service._validated_plan` raises one +`PlanSyncConflict` for selected parser/graph categories. `PlanParseResult` +exposes several useful findings but has no stable codes, spans, remediation, or +project-level validation. + +**Implementation decision:** Add `plan_validation.py` with a `Finding` value: +`code`, `severity`, `message`, `remediation`, `path`, `line`, `column`, and +optional TOML/section/item pointer. Code families are fixed: + +- `PLAN-Dxxx` document/section findings; +- `PLAN-Mxxx` manifest/schema findings; +- `PLAN-Wxxx` work-item findings; +- `PLAN-Gxxx` dependency-graph findings; +- `PLAN-Pxxx` policy/secret/path findings; +- `PLAN-Axxx` admission-context findings. + +Parsing failures local to one area do not stop checks on areas already parsed. +Errors block; warnings and information never silently become errors. Findings +sort by source position then code, making CLI and JSON stable. Existing +dependency diagnostics are adapted, not reimplemented. + +Backend-specific config validation runs only when an explicitly selected +adapter is installed and compatible. Unknown/unloadable adapters are ordinary +blocking findings. The adapter receives only its config and plan context; it +does not mutate or probe the host at this stage. + +In target local mode, dependency validation also rejects required `external:` +edges and unresolved cross-project edges. A cross-project edge binds the exact +current local plan revision it observed so a later revision cannot silently +change what satisfied the dependency. Human decisions must name their owner and +blocking effect in the plan; admission does not guess their answer. + +**Primary surfaces:** `plan_validation.py`, `plan_service.py`, `schemas.py`, +`execution_environments.py`, new `delivery_backends.py`, tests for each. + +**Required tests:** a deliberately broken plan producing at least one finding +from every family in one run; stable ordering/codes; machine-readable JSON; +unknown adapter; broken adapter; adapter validation finding; secret-shaped +content rejection; required/advisory external dependency; bound/unresolved +local cross-project dependency; no project/queue/event row changes; a +regression proving the validator never calls a model. + +**Acceptance evidence:** `examples/PLAN.md` with its completed v1 block validates +cleanly; mutation tests for every required field demonstrate fail-closed +coverage. + +**Depends on:** P1, E0, L1. + +**Non-goals:** semantic architecture review, environment readiness, image +pull/build, Git mutation, or queue creation. + +### P3: Expose validation consistently through CLI, API, and GUI + +**State:** ready after P2; exploration complete. + +**Outcome:** A user can validate a plan cheaply in their preferred interface +and receives the same findings everywhere. + +**Current footing:** top-level `agent-harness plan PATH --dry-run` parses items +and is framed around GitHub sync; `/api/plan/parse` returns the old parse view; +the GUI's plan page previews remote issue changes. + +**Implementation decision:** Introduce the nested CLI contract +`agent-harness plan validate PLAN.md --work REPO [--json]`. Move existing +remote issue behaviour to `agent-harness plan publish-issues` with a documented +pre-alpha compatibility alias for one release. Add +`POST /api/plans/validate` returning a typed `PlanValidationResult`; the request +names a host-local plan and worktree path. Add a non-mutating GUI validation +form and findings table with code, location, severity, and remediation. + +All surfaces call one application service. HTTP 200 carries a completed report +even when invalid; transport/input failures use 4xx. CLI exit is 0 when valid, +2 when findings contain errors, and 1 for an operational failure. Validation +never creates a browser review session because it has no effect to approve. + +**Primary surfaces:** `__main__.py`, `api.py`, `ui.py`, `schemas.py`, +`templates/plans.html`, a findings partial, `plan_service.py`, CLI/API/UI tests. + +**Required tests:** byte-identical finding payloads across service callers; +OpenAPI descriptions for every field; docs endpoints unauthenticated but data +authenticated; GUI escaping of malicious plan prose; CLI exits/output; +backward-compatible remote alias warning; no external client construction. + +**Acceptance evidence:** the intentionally broken acceptance fixture prints one +complete report in text and JSON, and no question is asked. + +**Depends on:** P2. + +**Non-goals:** admission, project creation, semantic review, or remote sync. + +### P4: Make every plan authoring path emit the target shape + +**State:** ready after P2; exploration complete. + +**Outcome:** The copyable example, deterministic demo, optional inception, and +survey output all target the same plan contract. + +**Current footing:** `examples/PLAN.md` has the required prose but intentionally +lacks the manifest. `inception.render_plan` and `survey` emit the old Goal/Not +doing/Work shape. The current uncommitted exploration defaults recommend +private hosted repositories, CI runners, Compose, orchestration, publication, +and third-party gate policy. + +**Implementation decision:** Complete `examples/PLAN.md` with a safe manifest +using the installed Docker execution backend and shipped local-process delivery +backend. Validation does not pull the example image or start its process; +demo/acceptance tests substitute deterministic metadata adapters while proving +the same contract. Add +`examples/PLAN-TEMPLATE.md` as a placeholder-bearing authoring copy that is +expected to fail until filled. Refactor `render_plan` to emit every required +section and an explicit manifest proposal. Inception/survey remain optional +drafting tools and must run the deterministic validator before returning +`usable=true`. They may propose unresolved values as placeholders, but must +then report the validator's rejection; they cannot invent them. + +Remove hosted repository/CI/orchestrator/topology choices from generic +exploration defaults. Keep generic evidence, safety, local integration, and +acceptance principles. Do not add a third-party gate registry or policy while +D8 is open. + +**Primary surfaces:** `examples/PLAN.md`, new `examples/PLAN-TEMPLATE.md`, +`demo.py`, `inception.py`, `survey.py`, `exploration_defaults.py`, usage docs, +and their tests. + +**Required tests:** generated plans contain every required section and one +manifest; no hosted provider/topology default; unresolved values make a draft +invalid rather than guessed; current item IDs/dependencies survive generation; +manual and generated plans take the same validator path. + +**Acceptance evidence:** a fresh user can copy the template, see all deliberate +placeholder findings at once, fill it, and reach a clean deterministic report. + +**Depends on:** P2, L2. + +**Non-goals:** making inception mandatory, automatically answering questions, +creating a repo, or admitting work. + +## 5. Reviewed and atomic admission + +### A1: Persist immutable plan revisions and current membership + +**State:** ready after P1; exploration complete. + +**Outcome:** The queue can retain exactly what was admitted, which project/base +it described, and which work items belong to the current revision. + +**Current footing:** `projects` holds mutable `plan_path`/`plan_branch`; `plans` +holds one mutable local integration projection and a digest; `work` is keyed by +project/item; no exact plan text or immutable admitted configuration survives a +file edit. Git item branches, unlike queue rows, currently default to the global +`harness/` namespace and can collide when two projects share a repo. + +**Implementation decision:** Add, through idempotent queue migrations: + +- `plan_revisions(project_id, revision, plan_digest, manifest_digest, + plan_markdown, manifest_json, repository_identity, initial_base_sha, + integration_ref, finalise_ref, adapter_versions_json, admitted_by, + admitted_at)` with an immutable unique + `(project_id, revision)` and `(project_id, plan_digest)`; +- `plan_revision_items(project_id, revision, item_id, ordinal, title, brief, + deliverable, depends_on_json, acceptance_json, active)` as the immutable + item snapshot; +- `plan_questions` for advisory/blocking semantic-review output; +- `projects.current_plan_revision` as the mutable pointer. + +Keep `plans` as the mutable Git integration projection; add its revision link +rather than making immutable plan content mutable. Add membership-aware queue +queries so claims and whole-plan readiness consider only current active items. +Historical work rows and item outcomes remain inspectable. + +Exact plan Markdown is stored only after P2 confirms no known/credential-shaped +secret. Its byte digest is computed before parsing. The manifest also gets a +canonical semantic digest for idempotency across irrelevant TOML formatting. + +**Primary surfaces:** `work.py`, new `plan_revisions.py` or a tightly scoped +queue component, `graph.py`, `query_service.py`, migration tests. + +**Required tests:** upgrade from every repository fixture schema; immutable old +revision after a new one; same bytes idempotent; different bytes/same semantic +manifest remain distinct source revisions; current membership controls claims; +backup/reopen; secret-shaped plan never reaches the table. + +**Acceptance evidence:** editing the source `PLAN.md` after admission changes no +stored revision or queued brief, and the API can return the accepted digest and +snapshot after restart. + +**Depends on:** P1. + +**Non-goals:** running validation, creating Git refs, starting work, or making +the audit store mutable. + +### A2: Implement admission preview and batched semantic review + +**State:** ready after P2 and P3; exploration complete. + +**Outcome:** A valid plan produces one reviewable proposal bound to exact local +facts; optional semantic review can return all material questions without +manufacturing plan content. + +**Current footing:** browser plan sync already binds apply to plan digest and a +remote preview; browser project configuration binds updates to a version. +`inception` has question objects but is an authoring conversation, not +admission. There is no post-minimum plan reviewer. + +**Implementation decision:** Add `admission_service.preview`. It: + +1. runs P2 validation; +2. resolves canonical repository/worktree identity and exact `base_ref` SHA + without fetch or mutation; +3. calculates effective project/profile/delivery configuration and adapter + versions; +4. compares an existing current revision and reports explicit additions, + changes, and omissions; +5. optionally calls the new `plan_reviewer` role once and parses one JSON array + of questions with section/item pointer, blocking/advisory severity, and why; +6. returns a canonical proposal digest covering all of the above. + +Semantic review is off unless requested and routed. It cannot turn a +deterministic error into a question. A malformed reviewer response fails that +optional review closed but does not corrupt the deterministic report. Blocking +questions prevent apply; advisory questions are shown and later persisted. + +**Primary surfaces:** new `admission_service.py`, `model_client.py` role usage, +`schemas.py`, browser review payloads, unit tests with injected Git/model probes. + +**Required tests:** no model by default; exactly one model call when requested; +all questions returned together; invalid plan makes zero calls; stale plan/base/ +adapter facts alter proposal digest; reviewer output cannot add commands; +redaction; D9 item-review prompt byte-for-byte unchanged. + +**Acceptance evidence:** a valid plan with three semantic ambiguities produces +one report containing all three and no durable project/queue state. + +**Depends on:** P2, P3. + +**Non-goals:** interactive question answering, editing the plan, judging item +code, or probing whether the execution host is ready. + +### A3: Apply admission atomically and idempotently + +**State:** ready after A1 and A2; exploration complete. + +**Outcome:** One approved proposal creates exactly one stopped project, plan +revision, active item membership, work projection, and typed graph—or nothing. + +**Current footing:** `configure_project`, `WorkQueue.add`, graph edge writes, +and `PlanCoordinator.ensure` are separate operations. `WorkQueue.add` writes +item-by-item in autocommit mode, so a mid-load failure can leave a partial plan. + +**Implementation decision:** Add `admission_service.apply` and one queue method +that opens `BEGIN IMMEDIATE`, rechecks the expected current revision, writes the +project/revision/items/work/graph/current pointer, and commits. Refactor internal +queue helpers to accept an existing connection; do not coordinate this by +calling public autocommit methods in a loop. + +The apply request carries proposal digest, plan digest, expected base SHA, +expected current revision, explicit removed-item decisions, and operator. Apply +reruns deterministic validation and local Git identity checks before opening the +transaction. A mismatch returns a typed stale-proposal conflict and writes +nothing. Duplicate idempotent apply returns the original revision. + +Local plan-ref creation is deliberately outside the database transaction. It +occurs idempotently during preflight/start from the stored revision/base SHA; +admission itself mutates no Git state and starts no fleet. + +Before the transaction, apply also revalidates that `integration_ref` is +harness-owned or absent and does not equal the base/finalisation ref. Item refs +are derived as `refs/heads/harness//r/` (with +validated/normalised components), not the current global `harness/`. +This makes `(project_id, item_id)` queue isolation true in Git as well as +SQLite. Existing legacy refs remain readable and are migrated/replayed only by +an explicit revision action; admission never renames them silently. + +**Primary surfaces:** `admission_service.py`, `work.py`, `graph.py`, +`project_service.py`, API schemas, atomicity tests with injected failure points. + +**Required tests:** failure after each logical insert rolls back all tables; +concurrent apply yields one revision; replay returns same revision; graph and +work snapshot agree at commit; project stopped; no Git ref/session/model/remote +write; stale base and changed plan refused; queue reopen retains all facts; +integration/base/finalisation ref collision; two projects with item `W1` in one +repository derive distinct refs. + +**Acceptance evidence:** a forced exception immediately before commit leaves +the database byte-for-byte logically unchanged; a successful retry creates all +expected rows once. + +**Depends on:** A1, A2. + +**Non-goals:** image preparation, preflight readiness, claiming work, or remote +issue creation. + +### A4: Define and implement safe plan revision semantics + +**State:** ready after A3; exploration complete. + +**Outcome:** A user can revise an admitted plan without silently dropping, +re-running, or relabelling existing work. + +**Current footing:** `WorkQueue.add` refreshes title/brief/dependencies, +preserves done, and revives failed/blocked work when its brief changes. It does +not know current plan membership, acceptance text, or explicit removal. + +**Implementation decision:** Revision is allowed only while the project is +stopped and has no claimed or held items. Preview classifies every ID: + +- unchanged: retain work state and membership; +- wording/acceptance/dependency changed: snapshot the new spec; pending stays + pending; failed/blocked revival keeps the existing `revives` rule; done stays + done only when deliverable and acceptance are unchanged, otherwise preview + requires explicit `reopen` approval; +- added: create pending work; +- omitted: block apply unless `removed_items` explicitly names ID and reason; +- explicitly removed pending/unstarted: retain history, mark inactive, record + `removed_by_plan_revision`; +- explicitly removed started/done: retain outcome and make inactive only after + high-risk explicit approval naming that state. + +Dependency edges for the current graph derive only from active current items. +An active item depending on a removed local item is a deterministic error unless +the dependency is changed to an explicit external/decision target. Overrides do +not cross graph revisions. + +The base SHA never follows a moving branch implicitly. If a new revision +resolves `base_ref` to a different SHA, preview names the move. Apply persists +the new immutable SHA, and before claims resume the integration coordinator +rebuilds from it and replays retained completed code items through the same +promotion checks. A replay conflict or gate failure blocks the revision with a +durable outcome; it does not discard accepted history or start an agent +silently. Changing `integration_ref` for an existing project is not supported +in v1; use local finalisation or create a new project identity. + +**Primary surfaces:** admission diff model, `work.py`, `graph.py`, query/API/UI +projections, revision tests. + +**Required tests:** every classification above; changed done acceptance; omitted +item refusal; explicit removal audit; active/held revision refusal; revision +number monotonicity; stale override invalidation; current readiness excludes +inactive history without deleting it; moved base replay success/conflict/gate +failure/crash recovery; no implicit refresh when only the source branch moves; +integration-ref change refusal. + +**Acceptance evidence:** revising a four-item plan to add one, rewrite one, and +explicitly remove one produces the reviewed state exactly, while an accidental +omission is rejected. + +**Depends on:** A3. + +**Non-goals:** automatically deciding delivered equivalence, deleting rows, or +allowing a live plan to move under active workers. + +### A5: Publish the admission workflow through CLI, API, and GUI + +**State:** implementation in progress; CLI and typed API foundations are implemented, GUI confirmation and exit evidence remain. + +**Outcome:** Users have one non-interactive validation/admission path, with an +explicit review step for mutation. + +**Current footing:** API/browser project registration and remote plan sync are +separate. The CLI can load a plan directly into the queue during `run` without +the target validation or atomicity. + +**Implementation decision:** Add: + +- `agent-harness plan admit PLAN.md --work REPO --dry-run + [--semantic-review]` to print the proposal and digest; +- `agent-harness plan admit ... --approve-digest DIGEST` to apply without an + interactive prompt; +- `POST /api/plans/admission/preview` and `/apply`; +- `GET /api/projects/{project_id}/plan-revisions` and one revision detail; +- GUI preview/confirmation using the existing opaque browser-review mechanism. + +Remove direct `--plan` queue loading from the target `run` path or route it +through admission. Keep a clearly named legacy compatibility path only while +documented. Apply routes require identity, CSRF in browser, exact review token, +and typed conflict responses. + +**Primary surfaces:** `__main__.py`, `api.py`, `ui.py`, templates, schemas, +`browser_session.py`, `runtime.py`, `docs/USAGE.md`, surface tests. + +**Required tests:** no stdin prompt; invalid plan returns all reasons; semantic +questions batch; preview creates nothing; apply consumes review once; stale +review refused; replay idempotent; OpenAPI complete; browser escaping/auth/CSRF; +legacy direct load cannot bypass target validation. + +**Acceptance evidence:** starting from a copied template, a user validates, +reviews, admits, restarts the service, and sees one stopped project with the +exact revision and queue—without GitHub credentials. + +**Depends on:** P3, A3, A4. + +**Non-goals:** automatically starting work, creating a repository, or pushing a +branch. + +## 6. Per-project execution profiles + +### E0: Version the execution-backend configuration contract + +**State:** ready after P1; exploration complete. + +**Outcome:** Deterministic plan validation can ask an installed execution +backend whether its opaque config is structurally valid without probing or +mutating the host. + +**Current footing:** `execution_environments.py` discovers factories through +installed metadata, but contract v1 exposes only `check()` and `create(...)`. +`check()` probes runtime availability and `create()` starts an item resource; +neither is a valid operation during read-only plan validation. Environment +settings are separate keyword arguments rather than a backend-owned config. + +**Implementation decision:** Define execution-backend contract v2 before P2 +uses it. In addition to metadata description and later runtime operations, it +exposes `validate_config(config, context) -> tuple[Finding, ...]` and +`canonicalize_config(config) -> JSON-compatible mapping`. Both are +deterministic and effect-free. The validator owns generic image/mount/network/ +limits fields; the backend owns only `[execution.config]`. Contract lookup can +report metadata/API compatibility without calling `check()` or importing any +unselected adapter. E4 adds prepare/inspect/cleanup runtime operations to this +same v2 contract rather than raising the version a second time. + +The shipped Docker adapter implements v2 through entry-point metadata. Upgrade +is fail-closed for target revisions; legacy global projects remain readable and +are reported outside the target until explicitly admitted. + +**Primary surfaces:** `execution_environments.py`, execution-environment +protocols, Docker adapter, metadata in `pyproject.toml`, fake backend tests, +genericity list. + +**Required tests:** config validation makes no subprocess/network call; +canonical output/digest stability; incompatible v1/broken/unknown backend; +unselected backend not imported; adapter exception becomes a typed finding; +Docker generic/runtime fields cannot hide inside opaque config; existing +legacy environment fixtures have an explicit compatibility path. + +**Acceptance evidence:** a separately installed fake v2 backend validates a +fixture plan by name with no core edit and no call to its deliberately failing +runtime `check()`. + +**Depends on:** P1. + +**Non-goals:** preparing an image/service, proving the backend is installed on +the eventual worker host, or adding a topology-specific field to core. + +### E1: Persist the effective execution profile per plan revision + +**State:** ready after A1 and E0; exploration complete. + +**Outcome:** Every claim can name the immutable environment it is meant to use, +without reading deployment-wide image or mount settings. + +**Current footing:** `EnvironmentSpec` already describes image, worktree, +mounts, environment names, network, and limits. Docker and host backends exist. +`__main__.py` currently stores `execution_backend` and `execution_image` as +global settings and closes over one mount tuple when it builds all project +executors. + +**Implementation decision:** Add an immutable `execution_profiles` record keyed +by project/revision with: + +- semantic profile digest; +- backend name, API version, and installed adapter version; +- image strategy and reviewed source reference/recipe; +- prepared immutable image reference and content ID/digest, initially null; +- repository-relative mount declarations resolved at admission; +- secret names/scopes, never values; +- network policy and backend config; +- probes, resource limits, role runner, and required roles; +- preparation state, timestamps, and last typed failure. + +The admitted manifest remains the source; this record is its effective, +queryable projection. A source mount in v1 is repository-relative and must not +escape the repository. Host-global dependency paths are not portable plan +facts: an installed backend may expose a named binding in its config, and +admission must show the operator's binding explicitly before it becomes part of +the effective digest. + +Add the effective profile to project/revision API views. Keep global settings +only as migration/preview defaults. Once a project is admitted, changing a +global default changes neither its digest nor its executor. + +**Primary surfaces:** `work.py` migrations, `plan_revisions.py`, +`execution_environment.py`, `project_service.py`, `schemas.py`, query/API/UI +views, profile persistence tests. + +**Required tests:** two revisions retain two profiles; two projects hold +different backends/images/mounts simultaneously; mount traversal/symlink escape +refused; secret value absent from every row/API/event; canonical digest stable; +unknown adapter version change makes a new preview; old global-only projects +remain readable but not target-ready. + +**Acceptance evidence:** after restart, preflight can reconstruct the exact +profile for each of two projects without any environment-backend/image/mount +CLI flag. + +**Depends on:** A1, P1, E0. + +**Non-goals:** building an image, injecting a secret, starting services, or +claiming work. + +### E2: Implement reviewable runner-image preparation and pinning + +**State:** ready after E1, E3, and A5; exploration complete. + +**Outcome:** An admitted profile reaches a locally available immutable runner +image through an explicit, auditable preparation action. + +**Current footing:** the Docker backend accepts an image string and records the +container image ID after start. It neither builds an image nor proves the image +before work. Passing a tag can therefore select different bytes later. + +**Implementation decision:** Add an `image_preparation` application service +with two v1 paths: + +1. **Existing image.** Require a digest-qualified reference or local image ID. + Preparation may pull only when `pull = true` is present in the reviewed + manifest. It then inspects and stores the actual content ID/repository + digest. A mutable tag alone is invalid. +2. **Built image.** Build from a detached worktree at the admitted base SHA. + Support either a repository-owned Containerfile or a generated recipe. + A generated recipe has an immutable base image, explicit repository files + to copy, and JSON-form setup argv arrays. Core renders only generic + `FROM`, `WORKDIR`, `COPY`, `RUN [argv]`, user, and label instructions; it + never translates “Python”, “Rust”, or a system-package name into a package + manager command. The reviewed plan supplies those commands. + +The preview shows the source commit, Containerfile or generated text, copied +paths, build args/names, build network (`none` or explicitly `egress`), and +resulting ownership labels. Secret build values use the backend's secret mount +facility and are never rendered into the recipe or image history. The recipe +digest and resulting image content ID are durable. Work starts by content ID, +not by the temporary build tag. + +Preparation is a reviewed mutation because it may pull data, run setup +commands, and create local images. It has a per-profile lease and is idempotent: +an already prepared matching digest is inspected and reused. A changed source +or recipe requires a new plan revision/preview. + +`system_packages` and `toolchains` remain review/evidence declarations; they do +not generate setup commands. For a repository Containerfile, the validator +requires matching probes but cannot prove how the image installed them. For a +generated recipe, all installation work appears explicitly in the manifest's +`setup` argv arrays and is rendered verbatim as JSON-form `RUN` instructions. + +**Primary surfaces:** new `image_preparation.py`; execution-backend contract; +Docker adapter build/inspect functions; API/CLI/GUI review; artifact/event +recording; image tests with an injected backend plus opt-in live Docker tests. + +**Required tests:** mutable tag rejection; pull disabled/enabled; exact reviewed +recipe; JSON argv escaping; context path confinement; symlink escape; no secret +in recipe/log/history evidence; failed build remains unprepared; concurrent +prepare builds once; killed build lease recovery; content ID used at create; +live tiny image build behind an explicit environment marker. + +**Acceptance evidence:** delete the temporary tag after preparation and prove a +worker still starts from the stored content ID; changing a setup argv invalidates +the profile rather than reusing the image. + +**Depends on:** E1, E3, A5. + +**Non-goals:** automatically choosing a base image/package manager, publishing +an image, running product deployment, or treating an image scan as a new gate. + +### E3: Bind named secrets without persisting their values + +**State:** ready after E1; exploration complete. + +**Outcome:** A plan can declare which credentials a stage needs, while values +remain outside the plan, operational database, prompts, and evidence. + +**Current footing:** route/service secrets are read from known environment +variables and exact values feed the redactor. Execution environments accept a +mapping of environment values, but the project contract does not declare or +scope it and current stores know only a fixed shortlist of secret names. + +**Implementation decision:** Add a `SecretResolver` protocol supplied by the +deployment, with a default environment resolver. The plan stores only +`name/source/scope`. At preflight the resolver answers present/absent and returns +an opaque in-memory value only to the stage that owns the scope. Values are +added to one process-wide, thread-safe redaction registry before any +command/model output can be persisted. `EventStore`, `AuditStore`, notification +outbox, model client, and artifact writer all use that shared live callable; +the current startup pattern creates separate frozen `from_environment()` +instances, so merely changing one store would leave another durable sink open. +Registered values remain in the registry for the process lifetime—even after +stage injection ends—because late logs and asynchronous events can still echo +them. They are never enumerable through an API. + +Scopes are fixed in v1: + +- `provisioning`: image pull/build and dependency provisioning only; +- `checks`: item/integration execution environment only; +- `delivery`: local delivery backend/check context only. + +No scope implies another. Secrets are passed by container environment or +backend secret mount as selected by the backend; they never enter template +variables shown to an agent unless an adapter contract explicitly requires a +named value and documents that exposure in preview. Controller model-route +credentials remain controller concerns and are not project secret entries. + +**Primary surfaces:** new `secrets.py`, `redaction.py`, service composition in +`__main__.py`, event/audit/notification/model/artifact construction, preflight, +execution and delivery requests, Docker adapter, schemas and tests. + +**Required tests:** missing name blocks the owning stage; unrelated missing +secret does not; value absent from SQLite, event/audit JSON, API/OpenAPI +examples, generated recipes, process argv, and error detail; dynamic value is +redacted through every durable sink even when registered after the sinks open; +concurrent registration/redaction; resolver exception is a named preflight +failure; injection scope closes after use while redaction knowledge remains. + +**Acceptance evidence:** a fixture secret deliberately printed by a failing +command appears only as `[redacted]`, while the delivery record says which +secret name/scope was used. + +**Depends on:** E1. + +**Non-goals:** becoming a secret manager, rotating credentials, storing +encrypted values, or injecting controller credentials into agents. + +### E4: Add project-scoped supporting services and network preparation + +**State:** ready after E1, E3, and E0; exploration complete. + +**Outcome:** Test databases, caches, and similar agent-time dependencies can be +declared and owned per project without hardcoding their technology in core. + +**Current footing:** the item Docker backend creates one container on `bridge` +or `none`; it has no project service lifecycle and no per-project network. +`supporting_services` currently exists only as target prose. + +**Implementation decision:** Raise the execution-backend API version with +explicit operations to validate config, prepare a project-revision runtime, +describe it, create item environments, inspect it, and clean it. Preparation +returns a durable opaque binding containing only safe resource identities. Core +never interprets a service's format. + +The shipped Docker adapter supports a repository-owned, admitted Compose file +for **agent-time supporting services**. It: + +- validates `docker compose config` against the detached admitted tree; +- derives a unique project name from harness project/revision, never user + shell text; +- applies harness ownership labels and creates a project-scoped network; +- starts only declared service names, with no host ports unless explicitly + reviewed; +- joins each item container to that network; +- records health/service identities and cleans only labelled owned resources; +- refuses external volumes, host networking, privileged mode, Docker-socket + mounts, or unowned resource deletion in v1. + +The v1 network policy is `none` or `project`. `project` reaches only the +backend-owned supporting-service network; it is created as internal, so it is +not generic internet egress. Dependency downloads belong in the reviewed image +preparation action. A later egress-capable backend can expose that as explicit +adapter config; core must not claim destination enforcement it cannot provide. + +**Primary surfaces:** execution environment protocols and lookup, Docker +adapter, profile persistence, preflight, recovery/reaper, tests and live Docker +fixture. + +**Required tests:** adapter compatibility version; service name/config +validation; per-project network separation; no host/privileged/socket escape; +health failure blocks; two projects with same Compose service names do not +collide; killed harness recovery; cleanup refuses an unlabelled resource; +item container resolves its service in an opt-in live test. + +**Acceptance evidence:** two concurrent project profiles use isolated same-named +database services and teardown one without affecting the other. + +**Depends on:** E1, E3, E0. + +**Non-goals:** production service orchestration, arbitrary outbound networking, +sharing mutable services across projects, or adding service-health gate types. + +### E5: Make preflight prove the exact admitted profile + +**State:** ready after E2–E4; exploration complete. + +**Outcome:** Project start is refused before a claim when the pinned environment +cannot run the work or its declared checks. + +**Current footing:** preflight checks worker presence, checkout, cleanliness, +base currency, disk, reviewer, role runner, and a deployment-wide execution +backend probe. Missing project checks are currently only a warning. It does not +inspect an admitted profile, pinned image, mounts, secrets, services, or tool +probes. + +**Implementation decision:** Extend preflight with blocking checks for: + +- current valid/admitted plan revision and unchanged local repository identity; +- integration ref availability/collision safety; +- compatible installed execution and delivery adapters at admitted versions; +- prepared pinned image and recipe/source consistency; +- all mount sources and named bindings; +- all required secret names by stage (without values in detail); +- supporting-service runtime readiness; +- every declared execution probe inside a disposable environment; +- every delivery command context is supported and every host-context program + is both named in `required_host_tools` and present on the harness host; +- the operational store is available, audit is not degraded, and the bounded + artifact root is writable with the shared redactor attached; target work may + not begin if it cannot retain the evidence its contract promises; +- non-empty item and integration checks; +- enough disk for configured worker concurrency and image/build workspace; +- current reviewer route and role runner required by the profile. + +Remote Git becomes a non-blocking optional extension whenever the admitted +target is local. Remove `force` as a way to start a target-profile project with +a blocking preflight failure; it would make fail-closed admission ceremonial. +Legacy projects may retain their existing explicit override during migration, +clearly reported as outside the minimum contract. + +Preflight stays read-only with respect to project work: image/service +preparation has its own explicit action. Disposable probe containers may be +created only after preparation and must be removed before the response; the +ordinary `/api/readiness` remains a non-mutating cached/capability view and +does not run them. + +**Primary surfaces:** `preflight.py`, readiness API projections, runtime +factories, doctor, GUI preflight, schemas and tests. + +**Required tests:** each blocker independently; complete all-at-once report; +probe timeout/missing program/version mismatch; no claim/model call; no remote +probe in local mode; project A profile cannot satisfy project B; start and +preflight share one result; cleanup after failed probe; no force bypass on +target projects; degraded audit/unwritable artifact root/missing shared +redactor block before spend. + +**Acceptance evidence:** remove one required compiler from the pinned image and +observe preflight refuse before attempt count/model spend changes; rebuild a +valid image and observe the same plan become ready. + +**Depends on:** E2, E3, E4, A5. + +**Non-goals:** preparing resources implicitly, measuring model quality, or +deciding whether a project's test assertions are sufficient. + +### E6: Run authoritative checks inside the admitted execution boundary + +**State:** ready after E1 and E5; exploration complete. + +**Outcome:** The pinned per-project environment is the environment that answers +the authoritative item and integration checks, not merely the environment in +which the implementation agent edited files. + +**Current footing:** `DockerItemEnvironment.run` accepts a command string and +uses `/bin/sh -lc`; the role-runner adapter needs that screened text interface +for an interactive agent. `CheckRunner`, however, always executes argv directly +on the controller host. After a container agent returns a diff, core applies it +to a controller worktree and invokes host checks. Today an image can contain +the promised compiler while the actual gate fails on the host—or the gate can +pass using an undeclared host tool the image does not contain. + +**Implementation decision:** Extend the execution-environment contract with a +distinct `run_argv(argv, *, cwd, timeout)` operation implemented with no shell. +Keep the screened role-runner text operation separate and clearly named. Change +`CheckRunner` to receive an injected argv runner; item checks and +promotion-time integration checks use a disposable environment reconstructed +from the admitted profile and mounted over the exact candidate/integration +worktree. The controller's `CommandGuard` screens argv before the backend call, +and the backend also enforces cwd confinement. Fix commands, when enabled, +follow the same runner and existing post-fix review/check invariants. + +Host compatibility remains fixture/legacy behaviour and is reported as no OS +boundary. A target Docker profile can never fall back to host execution after +environment creation or command failure. Environment evidence records the +profile/image digest and argv for each check result. + +**Primary surfaces:** `execution_environment.py`, `execution_environments.py`, +Docker adapter, `executor.CheckRunner`, `runtime.py`, `plan_integration.py`, +check/evidence schemas, genericity list, tests. + +**Required tests:** direct argv including spaces/metacharacters without shell; +missing tool in image while present on host; present in image while absent on +host; item and promotion checks use the same profile; fix/recheck uses it too; +cwd/mount confinement; backend exception is an honest escalation; no fallback; +container lifecycle cleanup and bounded/redacted output; the same runner +supports later delivery commands against a detached accepted tree. + +**Acceptance evidence:** a fixture installs its only check program in the +runner image, not on the controller, and both the item gate and promotion +re-gate pass; removing it from the image makes preflight/check fail before +review without host substitution. + +**Depends on:** E1, E5. + +**Non-goals:** removing the role runner's screened interactive shell protocol, +executing product deployment in the runner image, or weakening `CommandGuard`. + +## 7. Complete the local item and integration path + +### I1: Make local execution the default target path + +**State:** ready after A5 and E6; exploration complete. + +**Outcome:** An admitted target project can run with no repository host, remote +issue, push, pull request, hosted check, or remote credential. + +**Current footing:** `direct_executor_factory(..., push=False)` already supports +local item branches and `PlanCoordinator` promotion. CLI `--no-push` works. +Several command names, preflight docstrings, API summaries, and normal defaults +still frame a pull request as the definition of done. + +**Implementation decision:** Derive delivery mode from the admitted plan: +`local` is the only v1 minimum value and the default. Executor construction for +that mode receives no GitHub client and instantiates no `PlanPublisher`. +Repository-host metadata may remain nullable extension configuration but cannot +affect local readiness or completion. + +The normal target command becomes `agent-harness project start ID` (or the +existing API action) after admission/preflight. `run --no-push` remains a +compatibility/diagnostic surface. Ensure item branches, checkpoint commits, +review, and promotion are identical between local and optional publication +modes up to the explicit extension boundary. + +**Primary surfaces:** `runtime.py`, `__main__.py`, `preflight.py`, API/UI text, +`plan_publication.py` call boundary, README/USAGE, executor factory tests. + +**Required tests:** factories assert no GitHub construction/call; local project +with `repo=None`; remote outage cannot fail local item; optional publication +still works when selected; no push command in local events; reviewer/check/ +checkpoint/promotion ordering unchanged. + +**Acceptance evidence:** run a multi-item local fixture with the network +disabled after image preparation and observe accepted local branches and no +remote call. + +**Depends on:** A5, E6. + +**Non-goals:** deleting remote extension code, changing D1 for this repository's +own issue tracker, or weakening the reviewer gate. + +### I2: Extract whole-plan local completion from remote publication + +**State:** ready after A4 and I1; exploration complete. + +**Outcome:** Core can answer whether a plan is ready for local delivery without +importing or constructing a remote publisher. + +**Current footing:** `PlanPublisher.readiness` contains useful local counts but +lives in `plan_publication.py`; it does not bind readiness to current plan +revision membership or prove every code item reached the current integration +head. + +**Implementation decision:** Add `plan_completion.py` as a pure query/service. +For the current admitted revision it reports: + +- active item counts by state and IDs holding completion; +- code items with a successful promotion linked to that revision; +- findings items with a durable accepted findings result; +- exact integration ref/head and whether Git matches the durable projection; +- whether any promotion/refresh is in progress; +- whether the project is stopped with no live workers/claims/holds; +- the plan/profile digests to which the answer applies. + +Ready means every active item is done, every code deliverable is represented in +the integrated branch, every findings deliverable is durably accepted, no +integration journal action is unresolved, and the branch has not moved outside +the coordinator. It does not mean the product has built or deployed; that is a +separate delivery state. + +Refactor `PlanPublisher` to consume this service when optional publication is +used. Do not duplicate the rules. + +**Primary surfaces:** new `plan_completion.py`, `plan_publication.py`, +`plan_integration.py`, query/API/UI schemas, tests. + +**Required tests:** code vs findings; inactive historical item; failed/held/ +exhausted item; missing promotion; branch moved externally; target refresh in +progress; last claimed item exclusion is no longer an ad hoc publisher rule; +same answer in local and optional remote paths. + +**Acceptance evidence:** the completion endpoint names the exact one missing +promotion in a plan whose queue otherwise says all items done, then becomes +ready only after the durable promotion exists. + +**Depends on:** A4, I1. + +**Non-goals:** building/deploying, remote publication, local target-ref update, +or treating delivery failure as an item failure. + +### I3: Obtain live local-fleet evidence through integration + +**State:** evidence-bound after I1, I2, and E6; exploration complete. + +**Outcome:** A real daemon and real role-runner path complete a multi-item plan +through local promotion with no remote credentials. + +**Current footing:** deterministic fixtures cover the path and Stage 2 reached a +real daemon, but no retained run proves the complete admitted profile through +all items and promotions. + +**Implementation decision:** Use a small dedicated acceptance repository, not +this dirty development checkout and not a workload-coded core path. The plan has +at least three code items: two independent changes that may run concurrently +and one dependent change. It also has one findings item, one deliberate first +review rejection followed by correction, and authoritative checks that would +fail if a promotion were missing. + +Run with a real Docker daemon, the shipped role runner, a routed reviewer, a +prepared digest-pinned image, `push=false`, and a project-scoped supporting +service if E4 is exercised. Preserve plan/repo SHAs, profile/image digests, +route identities, event/audit DBs or exports, bounded artifacts, commands, +start/end times, and denominator counts. Credentials are redacted before the +package is retained. + +**Primary surfaces:** opt-in live acceptance test/runbook and a new append-only +evidence package; implementation fixes discovered by the run belong to their +own regression tests before rerun. + +**Required tests:** concurrency; dependency waiting; reviewer refusal/correction; +worker interruption and resume; promotion serialization; no remote calls; +complete plan-readiness answer. + +**Acceptance evidence:** every active item has an honest terminal outcome, all +code items are promoted at the recorded integration SHA, findings are retained, +checks/review gates are present in order, and blind spots are listed. + +**Depends on:** I1, I2, E6. + +**Non-goals:** product deployment, seven-day reliability, or claiming model +quality from one run. + +## 8. Final local product lifecycle + +### L1: Define the delivery-backend protocol and metadata discovery + +**State:** ready after P1; exploration complete. + +**Outcome:** A local product topology is selected by admitted name and can be +added without editing or being named by core. + +**Current footing:** execution environments already have an installed-metadata +contract. There is no separate product-delivery protocol. Treating the agent +container as the deployed product would conflate two trust, lifecycle, and +evidence boundaries. + +**Implementation decision:** Add `delivery.py` and `delivery_backends.py` with +entry-point group `agent_harness.delivery_backends`. API version 1 defines: + +- `validate(config, context) -> findings` — deterministic, no host mutation; +- `check() -> (ok, detail)` — backend availability; +- `preview(request) -> safe description` — exact resources/actions; +- `prepare(request) -> binding` — optional idempotent local setup; +- `deploy(request, binding) -> deployment_handle`; +- `inspect(handle) -> deployment_observation`; +- `teardown(handle) -> teardown_result`; +- `recover(record) -> recovery_result` for an interrupted owned deployment; +- `describe()` with name/API/implementation version. + +Requests carry project/revision/run IDs, source worktree, exact integration +SHA, artifact directory, safe environment/secret-name context, and immutable +adapter config. Handles/records must be JSON-serialisable, contain ownership +identity, and contain no secret value. Core owns ordering, timeouts, command +guarding, persistence, and outcomes; adapters own resource creation and +technology-specific inspection/cleanup. + +Add any new core delivery path modules to `EXECUTION_PATH`. The protocol +cannot register gates or add stages; D8 stays open. Shipped adapters are entry +points in `pyproject.toml`, and core contains no adapter dotted paths. + +**Primary surfaces:** new delivery modules, `pyproject.toml`, `tests/test_generic.py`, +fake adapter contract tests, schemas. + +**Required tests:** metadata-only discovery; unknown/incompatible/broken +adapter; deterministic config findings; handle JSON/secret validation; no +adapter import until selected; API compatibility; no registration terms in +`outcomes.py` or `attempts.py`. + +**Acceptance evidence:** a separately packaged fixture delivery adapter is +installed and selected by name with no core edit and passes the contract suite. + +**Depends on:** P1. + +**Non-goals:** implementing a topology, user-defined lifecycle stages, remote +deployment, or a third-party gate registry. + +### L2: Ship the local-process delivery adapter + +**State:** ready after L1; exploration complete. + +**Outcome:** A product that runs as one local process can be started, observed, +accepted, and reliably terminated. + +**Current footing:** the harness already runs guarded subprocess commands and +owns process metrics for itself, but has no product process ownership or +restart recovery contract. + +**Implementation decision:** The adapter config declares: + +- `start`: non-empty argv; +- optional repository-relative `cwd`; +- safe environment names/values supplied by core according to secret scope; +- `stop_signal` from a fixed safe set, graceful timeout, and kill timeout; +- optional local port declarations used only for collision preview/evidence. + +Deploy uses `subprocess.Popen(..., shell=False, start_new_session=True)`, captures +bounded stdout/stderr to run-owned artifact files, and records PID plus process +start time/process-group identity so PID reuse cannot authorise cleanup. It +refuses a start argv through `CommandGuard` before the process exists. Inspect +distinguishes running, exited, and identity mismatch. Teardown sends the +reviewed signal to the owned process group, waits, then force-kills only that +matching group. Recovery never signals a PID whose start identity differs. + +The adapter does not daemonise, write a host service definition, or infer a +health check. Core runs the plan's readiness argv separately. + +**Primary surfaces:** `adapters/local_process.py`, entry-point metadata, +artifact helper, unit/integration tests. + +**Required tests:** argv without shell; immediate exit; long-running process; +stdout/stderr bounds; signal/timeout escalation; PID identity mismatch; crash +and recover; command refusal before start; cwd/path confinement; two concurrent +runs do not collide; no child remains after test. + +**Acceptance evidence:** a fixture HTTP service starts from the exact detached +integration tree, passes a readiness client, and leaves no matching process or +port after teardown. + +**Depends on:** L1. + +**Non-goals:** supervisor/systemd installation, containers, remote hosts, +automatic restarts, or interpreting application logs as readiness. + +### L3: Ship the Docker Compose local-delivery adapter + +**State:** ready after L1 and E3; exploration complete. + +**Outcome:** A repository-owned Compose application can be built and run as a +locally owned delivery topology without making Compose a core assumption. + +**Current footing:** Docker CLI execution exists for agent containers and E4 +adds Compose for agent-time services, but product delivery needs a separate +project/resource identity and lifecycle. + +**Implementation decision:** Adapter config declares repository-relative +Compose files, optional profiles/services, allowed environment names, and +whether the adapter or the plan's build commands build images. Preview runs +`docker compose config` in the detached accepted tree and rejects v1 hazards: +external resources it cannot own, privileged mode, host PID/network, Docker +socket/device mounts, repository-external bind mounts, and unreviewed host +ports. + +Every run gets a unique Compose project name derived from project/revision/run, +plus harness ownership labels where Compose supports them. Deploy uses fixed +argv `docker compose up --detach` without remote registry push. Inspect records +container IDs, image content IDs, health, ports, and project labels. Teardown +uses the exact reviewed files/project name and removes only run-owned +containers/networks plus anonymous volumes. Named volume deletion is allowed +only when the plan declares it run-owned and the preview names it. Recovery +first verifies labels/config identity; ambiguity is an escalated cleanup result, +never a broad `down` guess. + +**Primary surfaces:** `adapters/compose_delivery.py`, entry-point metadata, +contract tests, opt-in live Docker tests, docs. + +**Required tests:** Compose config validation; unique project naming; +privileged/socket/host/external refusal; immutable image evidence; health +observation; teardown on success/failure; killed-controller recovery; unlabelled +resource refusal; no registry push; simultaneous same-service-name deployments. + +**Acceptance evidence:** a two-service fixture builds/deploys locally, passes +readiness/acceptance, tears down, and leaves no run-owned resource; another +project's resources remain. + +**Depends on:** L1, E3. + +**Non-goals:** Kubernetes/VM implementation, production Compose, registry +publication, or deleting shared named volumes. + +### L4: Add the durable fixed delivery journal and orchestrator + +**State:** ready after I2, E6, and L1; exploration complete. + +**Outcome:** The harness can execute and resume the fixed whole-plan local +lifecycle without confusing it with an item attempt or losing what happened +around a crash. + +**Current footing:** item attempts have a deliberately fixed six-stage list and +durable artifacts. Plan integration has a promotion journal and lease. No table +owns whole-plan delivery. + +**Implementation decision:** Add `delivery_runs` and `delivery_stages` to the +operational SQLite database: + +- run identity, project/revision, plan/profile/delivery digests; +- exact integration ref/SHA and detached worktree; +- adapter name/API/version and safe config digest; +- state (`pending`, `running`, `succeeded`, `failed`, `blocked`, `recovering`), + current fixed stage, owner lease, timestamps, failure/recovery kind; +- one append-only-within-run stage result for each fixed stage containing + argv/outcome/duration, bounded artifact checksums/references, adapter handle, + and redacted detail; +- immutable accepted-delivery record on success. + +Add `LocalDeliveryCoordinator` with a per-project delivery lease. It: + +1. rechecks I2 plan completion and exact branch SHA; +2. creates a detached run worktree at that SHA; +3. materialises delivery-scoped secrets in memory and extends redaction; +4. records `prepared` after backend preparation/recovery checks; +5. runs guarded build argv arrays in their declared runner/host context and + records `built`; +6. calls adapter deploy and durably records handle before proceeding; +7. polls adapter inspection plus readiness argv in its declared context until + pass/deadline; +8. runs acceptance argv arrays in their declared context once the deployment + is ready; +9. attempts teardown in `finally`, recording it independently; +10. records acceptance success only when build/deploy/readiness/acceptance pass + and required teardown succeeds. + +Commands use the same typed `CheckResult` classification where appropriate but +remain delivery-stage results, not item gate registration. No item state changes +because delivery fails. A failed delivered product blocks plan completion at +the delivery layer and requires a new plan revision/correction or an explicit +retry of a transient stage as policy permits. + +Resume rules are fixed: + +- `prepared` may be repeated idempotently; +- a durable successful build may be reused only from the same worktree/SHA and + artifact identity; +- a recorded deployment handle is inspected/recovered before any new deploy; +- readiness and acceptance may be re-run only under an explicit retry action + because they can observe mutable state; +- teardown/recovery is attempted before abandoning or superseding a run; +- an accepted run is immutable and never replayed. + +**Primary surfaces:** new `local_delivery.py`, queue migrations, guard/check +reuse, delivery adapter lookup, artifact/redaction support, reaper integration, +query schemas and exhaustive state-machine tests. + +**Required tests:** success ordering; build fail means no deploy; deploy fail; +process exit before ready; readiness timeout; acceptance fail; teardown fail; +both acceptance and teardown fail retained; lease contention/expiry; kill after +each boundary and recover; branch moves before start; branch moves during run +does not change pinned SHA; no item attempt stage added; no remote call; all +outputs bounded/redacted; runner-context delivery commands use the exact pinned +profile; host-context commands are direct argv and missing host tools block +preflight; no context fallback. + +**Acceptance evidence:** deterministic fault injection after every external +effect proves no duplicate owned deployment, no false success, no lost failure, +and eventual safe cleanup or explicit escalated residue. + +**Depends on:** I2, E6, L1, E3. + +**Non-goals:** a user-defined workflow engine, automatic repair by agents, +production rollback, or merging the local target ref. + +### L5: Add reviewed delivery, retry, recovery, and local finalisation actions + +**State:** ready after L4; exploration complete. + +**Outcome:** Operators can intentionally start and recover local delivery and, +after acceptance, optionally update a local target ref without any remote +effect. + +**Current footing:** project start/stop and many GUI actions already use typed +commands, CSRF, exact browser review, and audit. There is no delivery action or +safe local ref finalisation. + +**Implementation decision:** Add typed preview/apply operations and GUI/API/CLI +surfaces for: + +- prepare delivery resources; +- start a delivery for the exact ready integration SHA; +- retry an explicitly eligible failed/transient stage or create a new run; +- recover/teardown an interrupted deployment; +- finalise a successful accepted SHA into a configured local target ref. + +Start preview names plan/revision/SHA, profile/image digest, adapter/resources, +commands, secret names, timeouts, and cleanup behaviour. Apply uses an exact +digest/review token and rejects if plan completion, SHA, config, or adapter facts +changed. + +Finalisation is optional and high risk. V1 supports **fast-forward only** by +default, using `git update-ref refs/heads/TARGET ACCEPTED EXPECTED_OLD`. A plan +may explicitly request a merge commit, but implementing that is deferred until +there is evidence it is needed; v1 target success is the accepted integration +branch. Never update the currently checked-out branch when its worktree is +dirty or when Git cannot update it safely. Never push. Record old/new SHA, +operator, delivery run, and CAS result. + +Retry eligibility is deterministic: configuration/policy/acceptance failures +need a revised plan or explicit operator reason; transient backend/readiness +failures may retry after cleanup. Cost caps remain terminal and are never +treated as delivery transients. + +**Primary surfaces:** `command_service.py` rules/mutation owner or a parallel +typed delivery command service, `api.py`, `ui.py`, templates, `__main__.py`, +schemas, audit events and action tests. + +**Required tests:** auth/CSRF/review consumption; stale SHA/profile rejection; +start once/idempotency; retry matrix; recovery before redeploy; finalise FF; +non-FF/dirty/checked-out ref refusal; exact CAS race; no push; operator identity +and reason retained; GUI says accepted branch vs finalised ref distinctly. + +**Acceptance evidence:** one reviewed delivery reaches accepted; a second actor +moves the target ref before finalisation and the CAS refuses without overwriting +it; after a fresh preview the safe FF succeeds locally. + +**Depends on:** L4. + +**Non-goals:** automatic finalisation, force-reset, remote merge/push, production +approval policy, or treating browser navigation as authorisation. + +### L6: Expose one correlated local delivery evidence view + +**State:** ready after L4 and L5; exploration complete. + +**Outcome:** API and GUI can answer “what did this plan deliver locally?” +without requiring a reader to correlate raw logs or SQLite tables manually. + +**Current footing:** query/API/UI surfaces already project queue, attempts, +promotions, reviews, and events. Evidence is item-centric; no final lifecycle +exists. + +**Implementation decision:** Add a typed delivery summary containing: + +- project/current and delivered plan revision/digests; +- base, integration, accepted, and optional finalised local ref/SHAs; +- execution profile, runner image content ID, adapter names/versions; +- active item terminal states and promotion/findings references; +- build/deploy/readiness/acceptance/teardown/recovery stage results; +- command argv, outcome, duration, bounded output/checksum/artifact links; +- secret names/scopes and redaction markers, never values; +- operator reviews/decisions and remaining owned resource warnings; +- one honest overall state: not ready, ready, running, accepted, failed, + cleanup required, or superseded. + +The view is a projection over authoritative operational rows plus append-only +events/audit; controllers do not read SQLite directly. Artifact downloads stay +inside an allowlisted run directory, require auth, and cannot follow symlinks. +OpenAPI describes every field. GUI uses no CDN and clearly separates “item +accepted”, “plan integrated”, “product accepted locally”, and “local ref +finalised”. + +**Primary surfaces:** `query_service.py`, review/evidence service, `schemas.py`, +`api.py`, `ui.py`, templates/static assets, artifact route and tests. + +**Required tests:** every overall-state projection; incomplete/missing old data +shown as unknown rather than false; secret redaction; artifact traversal/ +symlink refusal; OpenAPI completeness; same query service for JSON/HTML; +accepted then superseded revision; cleanup-required warning survives restart. + +**Acceptance evidence:** a reviewer can diagnose a readiness failure and find +the exact command/output checksum, accepted commit, environment, and cleanup +state from the GUI alone. + +**Depends on:** L4, L5. + +**Non-goals:** rewriting audit events, embedding full unbounded logs, a separate +frontend, or claiming an unrun stage passed. + +## 9. Verification, proof, and release + +### V1: Build the deterministic end-to-end acceptance fixture + +**State:** planned deliverable; implementation is blocked on the unfinished GUI +admission-preview/confirmation workflow and the upstream lifecycle surfaces. + +**Outcome:** One no-network/no-model fixture exercises the complete target +contract and fails at every gate boundary when deliberately broken. + +**Current footing:** `init --demo`, Stage A, role-runner, integration, API, and +GUI fixtures each prove portions of the pipeline. None includes target +validation/admission/profile/final delivery. + +**Implementation decision:** Create a fixture application/repository at test +runtime. It has: + +- a complete v1 plan with at least three dependent items; +- a fixture execution backend and role runner with deterministic replies; +- authoritative item/integration checks; +- a fixture delivery backend exposing controlled build/deploy/readiness/ + acceptance/teardown failures; +- a local integration branch and exact evidence assertions. + +The main happy-path test validates, previews/applies admission, prepares the +profile, passes preflight, runs items, promotes, confirms plan completion, +reviews/starts delivery, passes every stage, tears down, and reads final +evidence. Parameterised tests break each boundary and assert the next expensive +or state-changing stage did not occur. + +Keep no sleeps: inject clocks, polling, and faults. This fixture proves wiring, +not container isolation, external model quality, or a real application. + +**Primary surfaces:** new test support plus targeted acceptance tests; demo may +be upgraded only after the fixture is stable. + +The ten-step implementation and acceptance plan is +[`docs/E2E-ACCEPTANCE-PLAN.md`](docs/E2E-ACCEPTANCE-PLAN.md). This is a new +project deliverable. It must not be represented as existing coverage while the +GUI admission flow is incomplete. + +**Required tests:** full happy path; all validator/admission/preflight/item/ +promotion/delivery failure cut-points; restart at durable boundaries; no +remote/network/model; exact final evidence; existing gates preserved. + +**Acceptance evidence:** one test output identifies the accepted SHA and every +stage, while tests demonstrate a failure before a costly gate prevents that +gate from running. + +**Depends on:** P3, A5, E6, I2, L6. + +**Non-goals:** live Docker proof, model evaluation, performance measurement, or +seven-day reliability. + +### V2: Complete a real project through local delivery + +**State:** evidence-bound after I3, L5, and L6; exploration complete. + +**Outcome:** One representative real application satisfies the entire minimal +contract with retained reproducible evidence. + +**Current footing:** real-agent observations and fixture integration evidence +exist separately. No project has completed validation through local product +acceptance/teardown. + +**Implementation decision:** Select a project supplied by the owner after the +software path is ready whose honest topology fits the shipped local-process +adapter. Rainmon is eligible only if that is its natural declared topology and +is never privileged; its plan must pass exactly the public template/validator +and may not cause core changes. A project that naturally requires Compose is a +candidate for V3 instead of being forced into this proof. + +Before spending, preserve: + +- repository origin description (if any), clean state, base and plan bytes/SHA; +- validation/admission proposal and approved revision; +- runner recipe/reference and pinned image content ID; +- installed adapter/role-runner/route versions; +- local topology preview, commands/timeouts, secret names, and redaction setup; +- denominators and explicit acceptance criteria. + +Run from a dedicated clean local checkout. Retain bounded raw artifacts with +checksums, exported operational state/events/audit, exact accepted SHA, local +resource inventory before/after, command transcript, outcome counts, and blind +spots. If a criterion fails, report it and keep the target incomplete. + +**Primary surfaces:** an append-only dated evidence package and regression fixes +for any discovered defect. + +**Required tests:** more than one item; real checks/reviewer; local integration; +real integrated build; local deploy; readiness; acceptance; teardown; no remote +mutation; at least one controlled failure/recovery rehearsal before the final +run where safe. + +**Acceptance evidence:** the package permits another operator with the same +declared local prerequisites to identify every input and repeat the run without +private conversational context. + +**Depends on:** I3, L2, L5, L6, V1. + +**Non-goals:** proving genericity from one project, pushing the accepted branch, +production deployment, or hiding project prerequisites. + +### V3: Complete a materially different second-project proof + +**State:** evidence-bound after V2; exploration complete. + +**Outcome:** A second application proves that adding a language/toolchain and +topology does not require changing harness core. + +**Current footing:** the genericity test guards names/imports and prior evidence +used multiple repositories, but the new minimal lifecycle has no two-project +proof. + +**Implementation decision:** The second project must differ from V2 in all of: + +- primary language/toolchain; +- dependency provisioning commands; +- authoritative check commands; +- local delivery adapter: V3 uses the shipped Compose adapter, while V2 uses + local process; +- data/UI applicability choices; +- at least one execution profile characteristic such as supporting service or + network policy. + +Before running, record `git diff -- src/agent_harness tests/test_generic.py` at +the V2 accepted implementation baseline. Project enablement may add only its +plan, repository-owned runner recipe/config, or a separately installed adapter +package. A needed core edit is a genericity defect: implement and test the +generic missing capability, then rerun **both** V2 and V3; do not call the +second proof complete on the new path alone. + +Run both projects concurrently for at least an overlapping item period to +exercise per-project profiles, services, budgets, leases, and non-starvation. + +**Primary surfaces:** second append-only evidence package and genericity +regression tests. + +**Required tests:** concurrent operation; distinct images/profiles; topology +isolation; independent teardown; no remote mutation; no execution-path project +name/path/toolchain constant; first project still passes. + +**Acceptance evidence:** the core diff required solely to onboard project two +is empty, or any generic fix has rerun evidence for both projects. + +**Depends on:** V2, L3. + +**Non-goals:** supporting every language/topology, benchmarking project quality, +or adding a project adapter to core. + +### V4: Measure reliability and destructive-boundary recovery + +**State:** evidence-bound after V2 and V3; exploration complete. + +**Outcome:** The minimum product has measured recovery evidence, not just one +green run. + +**Current footing:** leases, attempt resume, promotion recovery, redaction, and +fixture failure injection exist. The target adds image/service/delivery effects +whose crash behaviour needs direct measurement. + +**Implementation decision:** Run a published fault matrix against the two proof +projects or equivalent retained fixtures: + +- kill controller during image build, supporting-service preparation, item + model call, checks, reviewer, promotion, product deploy, readiness, + acceptance, teardown, and local finalisation CAS; +- restart and invoke read-only readiness/recovery preview first; +- recover explicitly and observe duplicate resources/effects, leaked resources, + repeated model spend, lost evidence, false success, and time to safe state; +- exercise simultaneous projects to confirm one recovery does not pause or + delete the other; +- exercise terminal provider cost-cap classification separately and prove no + retry/endpoint-wide stall regression. + +Publish denominators by cut point. A recovery marked manual or cleanup-required +is not a pass, but can be an honest release limitation if `minimal.md` acceptance +permits it; currently target teardown/recovery requires a declared safe outcome, +so unresolved owned resources block release. + +**Primary surfaces:** live fault-injection harness/runbook, evidence package, +regression tests for failures discovered. + +**Required tests:** attempted/recovered/manual/failed per cut point; duplicate +effects; leaked owned resources; extra model calls/cost; cross-project impact; +evidence completeness. + +**Acceptance evidence:** zero false-success outcomes and zero unexplained +run-owned residues across the stated matrix; any manual result is named and +keeps the relevant target criterion open. + +**Depends on:** V2, V3. + +**Non-goals:** chaos testing shared/production infrastructure, making uptime +claims from a short run, or retrying cost caps. + +### V5: Close release documentation, migration, packaging, and gates + +**State:** ready after V1–V4; exploration complete. + +**Outcome:** The repository exposes one coherent local product, can upgrade an +existing installation safely, and makes no claim wider than its evidence. + +**Current footing:** documentation is aligned to the target but the usage guide +still describes legacy current commands; plan/queue migrations have explicit +precedent; packaging already ships templates/static assets and adapter entry +points. + +**Implementation decision:** Before release: + +- make `examples/PLAN.md` a real valid target fixture and publish + `examples/PLAN-TEMPLATE.md`; +- rewrite the primary USAGE path around validate → preview/admit → prepare → + preflight/start → integrate → deliver → evidence; +- move remote issue/PR/publication material to an optional extensions section; +- document operational DB backup/export/rebuild/rollback for plan revisions, + profiles, services, and delivery journal; +- document safe recovery of adapter-owned local resources; +- update demo/doctor to report target capability honestly; +- update OpenAPI, GUI help, README status, `minimal.md` comparison, and + `docs/STATUS.md` milestone states; +- package new templates/static files and declare delivery adapters through + metadata; +- run a clean install/wheel smoke test with no optional remote credentials; +- run all four repository gates with fast `TMPDIR` and retain commands/results; +- verify every new execution-path module is covered by genericity enforcement; +- link V2–V4 evidence and leave unmet criteria unchecked. + +Legacy global execution settings/projects migrate as “legacy/unadmitted”, never +as fabricated target plan revisions. Users export/backup first and explicitly +admit a plan before those projects can claim under the target path. Existing +remote extension data is retained. + +**Primary surfaces:** docs, examples, demo/doctor, migration tooling/docs, +packaging metadata, OpenAPI/UI help, release evidence. + +**Required tests:** pre-migration DB upgrade/rollback/export; wheel contents; +fresh install demo; docs command smoke tests; link check; OpenAPI field +descriptions; genericity scans; no credential requirement on local first run. + +**Acceptance evidence:** all eight release criteria in `minimal.md` have direct +links to tests or dated evidence, the four gates pass, and status contains no +“done” claim for an unmet live criterion. + +**Depends on:** V1, V2, V3, V4. + +**Non-goals:** remote workflow certification, hosted CI/CD integration, +production deployment, or deleting historical evidence. + +## 10. Coverage matrix + +This matrix is the completeness check against `minimal.md` and the gaps in +`docs/STATUS.md`. + +| Target/gap | Backlog coverage | +|---|---| +| Generic plan template and normative schema | P1, E0, L1, L2, P4 | +| One-pass stable-code rejection | P2, P3 | +| No interview required | P3, P4, A5 | +| Optional post-minimum batched questions | A2, A5 | +| Atomic/idempotent admission | A1, A3, A4 | +| Local repository/base identity | A2, A3 | +| Dependency-aware active revision queue | P2, A1, A3, A4 | +| Per-project execution profile | E1 | +| Reviewed/pinned runner image | E2 | +| Named scoped secrets | E3 | +| Supporting services/network | E4 | +| Exact preflight | E5 | +| Authoritative checks use admitted profile | E6 | +| Collision-safe local Git refs and fixed base SHA | A1, A3, A4 | +| Local execution without remote prerequisites | I1 | +| Local plan completion | I2, I3 | +| Product delivery adapter boundary | L1 | +| Local process and Compose topologies | L2, L3 | +| Integrated build/deploy/readiness/acceptance/teardown/recovery | L4, L5 | +| Optional reviewed local ref finalisation | L5 | +| Correlated durable evidence | L6 | +| Deterministic full-path proof | V1 | +| One real project | V2 | +| Materially different second project/no core edit | V3 | +| Recovery and non-interference measurement | V4 | +| Migration/docs/package/gates | V5 | +| D8 remains unanswered | P2, L1, L4 genericity/non-goals | +| D9 review prompt remains controlled | A2 tests/non-goals | +| Remote workflows outside minimum | I1, V5 | + +## 11. First implementation tranche + +The smallest coherent first tranche is: + +```text +P1 -> E0 and L1 -> P2 -> P3 + P2 + L2 -> P4 +``` + +E0 and L1 establish effect-free adapter config validation; L2 makes the +published example name a real shipped local topology instead of a test-only +fiction. Together P1–P4 then deliver the user-visible behaviour that motivated +the target change: a generic plan template and one complete deterministic +rejection report, with no onboarding interview and no state mutation. + +Do not begin image generation or local deployment first. Without a versioned +admitted plan, those features would acquire configuration through new global +flags and recreate the project-specific harness behaviour this backlog exists +to remove. + +After M1, implement **A1 and A2 in parallel conceptually but merge A1 first**, +then A3–A5. The execution profile and delivery work can then proceed on stable +revision identities. The first live item spend should occur only after E6 can +prove and use the exact environment for both agent commands and authoritative +checks before a claim. + +## 12. Backlog exit rule + +An item is complete only when: + +1. its acceptance tests pass; +2. its documentation/API schema describes the implemented behaviour; +3. its genericity and safety assertions are encoded in tests; +4. its required deterministic or live evidence exists; +5. every dependency exit it relies on is actually met; +6. the four repository gates pass for code changes; and +7. `docs/STATUS.md` is updated honestly. + +Exploration text is not completion evidence. A checkbox must not be marked done +because code exists while the live or failure-path criterion remains unmet. diff --git a/GUI_PLAN.md b/GUI_PLAN.md index bad70ca..37fdb1a 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -1,7 +1,16 @@ # Agent Harness GUI Implementation Plan -**Status:** Imported into the current core tree on 2026-08-06; implementation remains -incomplete. `docs/STATUS.md` is the authority for current and remaining work. +> **Target alignment — 2026-08-09:** This is no longer a current product +> sequence. The generic local-only target is [`minimal.md`](minimal.md), and +> current state is [`docs/STATUS.md`](docs/STATUS.md). Remote Git, hosted CI/CD, +> publication, organisation-wide RBAC, and session-host expansion below are +> outside the minimum target. The implemented same-origin GUI remains part of +> the product and should expose local admission, integration, and delivery +> evidence as those capabilities land. + +**Status:** Historical implementation plan. Imported into the current core tree +on 2026-08-06; `minimal.md` and `docs/STATUS.md` now define the target and +remaining work. **Plan date:** 2026-08-05 **Product boundary:** The GUI is built, packaged, served, tested, and documented entirely inside `agent-harness`. diff --git a/README.md b/README.md index fc1ac0b..fe10ade 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,22 @@ # agent-harness -Turns a plan you wrote in markdown into work that coding agents actually do, and tells you -honestly what happened. +Turns a plan you wrote in Markdown into locally integrated, locally accepted +work that coding agents actually do, and tells you honestly what happened. ``` -PLAN.md ──▶ GitHub issues ──▶ claim ──▶ agent in a terminal ──▶ checks ──▶ review ──▶ PR - │ - └── you can attach to it, on any device +PLAN.md ──▶ validate ──▶ local queue ──▶ isolated agents ──▶ gates and review + │ + v +local Git integration ──▶ local build/deploy/accept/teardown ──▶ evidence ``` -It is **not tied to any particular project, language or workload.** You supply the plan, the -provider and the checks; the harness supplies the queue, the claims, the failure model and -the record of what happened. +It is **not tied to any particular project, language or workload.** You supply +the plan, local topology, execution profile, provider and checks; the harness +supplies the queue, claims, gates, local integration and record of what +happened. The target contract is [`minimal.md`](minimal.md). Remote Git, +hosted CI/CD and non-local deployment are optional extensions, not prerequisites. -## Status: pre-alpha — deterministic paths are tested; real use is observed, not proven +## Status: pre-alpha — foundations exist; the minimal local lifecycle is not complete It runs as a standalone service; [AIDevEnv](https://github.com/TheDancingDeveloper-org/aidevenv) is an optional reference session host, @@ -26,6 +29,11 @@ follow-up run, so it is an observation, not proof that the harness works against fleet. Deterministic fixture success proves wiring, not model quality or unattended reliability. +It also lacks deterministic admission of the new minimum plan, a pinned +per-project execution profile, and final local build/deploy/readiness/acceptance/ +teardown for the product under development. See +[`docs/STATUS.md`](docs/STATUS.md) for the capability-by-capability comparison. + Three words are used precisely throughout this README, and they are not interchangeable: @@ -102,35 +110,43 @@ worker killed mid-item releases it by doing nothing. A heartbeat keeps genuinely alive, because "slow" and "dead" look identical from outside and only a live process can keep stamping one. -## Definition of done for v1 +## Definition of done for the minimal local product -Expressed as observed behaviour, not internal completeness. +The full acceptance contract is in [`minimal.md`](minimal.md). In summary: -- [ ] The fleet runs 7 days unattended with no manual restart. -- [ ] Every failure is diagnosable from the GUI alone, without opening a log file. -- [ ] Rate-limit errors are classified, and cost caps are never retried. -- [ ] No single worker's failure pauses another worker. -- [ ] Reviewer-approved work survives a killed worker. -- [ ] Delivery rate is no worse than the workload's own pre-harness baseline, at lower cost. -- [ ] The role→model map can be changed without a redeploy. -- [ ] Two projects run concurrently without either starving the other. -- [ ] Deleting `harness.sqlite` changes no audited answer. +- [ ] An invalid minimum plan gets one complete deterministic rejection report + and creates no executable state. +- [ ] A valid plan and local Git repo are admitted atomically without remote + credentials. +- [ ] A pinned per-project execution profile passes preflight before work begins. +- [ ] Work survives the existing checks, review, durability, budget and policy + gates and is promoted into a local integration branch. +- [ ] The integrated commit builds, deploys locally, becomes ready, passes + acceptance, and tears down with durable evidence. +- [ ] Two materially different projects complete without changes to core + execution-path modules. +- [ ] The repository gates and genericity enforcement pass. -If all seven hold, v1 is done regardless of what remains unimplemented. +None of those unchecked outcomes should be inferred from component tests. ## Documentation -Two documents carry the current state. Everything else is a how-to, an -operational runbook, or history. +Four documents separate the target, implementation backlog, implemented +design, and current state. + +- **[`minimal.md`](minimal.md) — what the smallest useful generic product must + do.** This is the local-only target and includes the minimum plan template. +- **[`BACKLOG.md`](BACKLOG.md) — the exploration-complete work to reach that + target.** Each item fixes its boundary, dependencies, implementation + decision, failure behaviour, tests, evidence, and non-goals. - **[`docs/DESIGN.md`](docs/DESIGN.md) — how the harness works, and why it is shaped that way.** The execution pipeline, the invariants and the failure each one came from, model routing and failure classification, the dependency graph, durability, and the extension points. Start here to understand it. - **[`docs/STATUS.md`](docs/STATUS.md) — where it stands, and everything - outstanding.** What is proven, observed and merely tested; the open work in - the order it can be done; and how to run the harness against **rdpapp**, the - first application it is being tested against. + outstanding.** The direct comparison with `minimal.md`, retained foundations, + misalignments, milestone exits, and evidence boundary. ### How to use it @@ -181,6 +197,11 @@ It has no state field and is not kept in sync; GitHub is the tracker (D1). A five-minute tour. The full walkthrough, with real output, is in [`docs/USAGE.md`](docs/USAGE.md). +The commands below document the current pre-alpha implementation. Some still +expose optional GitHub publication because that capability predates the local +target. The end-to-end `minimal.md` path is not available until the gaps in +`docs/STATUS.md` are closed. + ### 0. See it work, before you configure anything ```bash diff --git a/docs/AUDIT-PLAN.md b/docs/AUDIT-PLAN.md index 7ab4c42..fe0e2da 100644 --- a/docs/AUDIT-PLAN.md +++ b/docs/AUDIT-PLAN.md @@ -5,6 +5,7 @@ > The durable audit layer, which is built. What survives is in `DESIGN.md`; the plan itself is history. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/COORDINATION-PLANE.md b/docs/COORDINATION-PLANE.md index d082ecc..588d2cd 100644 --- a/docs/COORDINATION-PLANE.md +++ b/docs/COORDINATION-PLANE.md @@ -4,8 +4,9 @@ > > **Half superseded, half still the only record.** The typed work graph (§8) is built and is described in `DESIGN.md`. The message ledger, rooms and oversight actor remain **proposed and not built**, and this document is still the only place they are specified — that part is not replaced by anything. > -> **Current documentation:** [`docs/DESIGN.md`](DESIGN.md) — how the harness -> works and why. [`docs/STATUS.md`](STATUS.md) — where it stands. +> **Current documentation:** [`minimal.md`](../minimal.md) — the generic +> local-only target. [`docs/DESIGN.md`](DESIGN.md) — how the harness works and +> why. [`docs/STATUS.md`](STATUS.md) — where it stands. > > Where this document and the code disagree, the code is right. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2ccc78a..4b432cb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -8,6 +8,16 @@ here. **Where a claim here disagrees with the code, the code is right and this document is wrong**; that is rule 7 of [`AGENTS.md`](../AGENTS.md) and it applies to this file first. +The target product boundary is now [`minimal.md`](../minimal.md): validated +plans, local agent execution, local Git integration, and local product +build/deploy/acceptance. This file still describes remote issue, branch, pull +request, and reconciliation capabilities because they exist in the pre-alpha +implementation. They are optional legacy/extension paths, not prerequisites or +the target lifecycle. Missing target capabilities are recorded only in +[`STATUS.md`](STATUS.md); their exploration-complete implementation work and +dependency spine are in [`BACKLOG.md`](../BACKLOG.md). This design document +does not duplicate that roadmap. + What is built, what is half-built, what has never run against a real fleet, and what is blocked on what: [`docs/STATUS.md`](STATUS.md) owns all of it. Nothing here is a status claim, and where a design element exists but is not yet @@ -17,7 +27,8 @@ reachable, this says so in one clause and moves on. ## 1. What this is, and the one idea -A queue and a delivery pipeline for a fleet of coding agents. +A queue and gated execution foundation for a fleet of coding agents. The +currently implemented remote path is: ``` PLAN.md ──▶ issues ──▶ claim ──▶ implement ──▶ checks ──▶ review ──▶ PR @@ -25,6 +36,11 @@ PLAN.md ──▶ issues ──▶ claim ──▶ implement ──▶ checks every stage recorded, append-only ``` +The target minimum path replaces the remote endpoints with deterministic plan +admission, a local queue and integration branch, then an integrated local +build/deploy/readiness/acceptance/teardown lifecycle. Those gaps are not +silently treated as implemented here. + You supply the plan, the model provider and the checks. The harness supplies the queue, the leases, the failure model, the gates and the record of what happened. It writes no code itself and judges no code itself; it decides *what diff --git a/docs/E2E-ACCEPTANCE-PLAN.md b/docs/E2E-ACCEPTANCE-PLAN.md new file mode 100644 index 0000000..bb31891 --- /dev/null +++ b/docs/E2E-ACCEPTANCE-PLAN.md @@ -0,0 +1,161 @@ +# Deterministic local end-to-end acceptance plan + +This is a planned project deliverable, not a claim that the full flow is +implemented today. It defines the first complete product-contract test for a +clean agent harness. + +The fixture is intentionally small: a temporary local Git repository, a tiny +application, and a plan with three or four dependent work items. The harness +path remains real. The test must use the actual plan contract, admission +transaction, queue lease, worktree, checks, review, integration, GUI session, +and durable evidence surfaces. Only model, network, and external-service +effects are replaced with deterministic test doubles. + +## Scope and prerequisites + +The fixture must include: + +- a repository created at test runtime; +- a predefined, valid `PLAN.md` committed or supplied as fixture input; +- a clean SQLite queue, event store, audit store, and browser-session state; +- a deterministic agent/runner backend with no credentials and no network; +- authoritative item and integration checks that can be deliberately failed; +- a small local delivery target whose build, readiness, acceptance, and teardown + commands are deterministic; +- browser coverage against the same-origin GUI and JSON API; +- exact commit, branch, revision, outcome, and cleanup assertions. + +The full test is gated on the GUI admission-preview and confirmation workflow. +Until that workflow is delivered, only the non-GUI portions may be implemented +as lower-level tests. The acceptance test itself must remain marked as pending +or skipped with a named prerequisite; it must not silently omit GUI coverage. + +This deliverable does not prove container isolation, model quality, provider +behaviour, performance, or multi-day reliability. Those are separate backlog +items. + +## Ten-step test plan + +### 1. Build the clean fixture + +Create a temporary Git repository containing the smallest application that can +be changed and locally exercised. Create fresh SQLite stores and deterministic +clock, polling, agent, delivery, and fault-injection dependencies. Assert that +the fixture starts with no project, revision, queue rows, claims, events, or +delivery journal entries. + +### 2. Supply and validate the plan + +Use the predefined fixture `PLAN.md`, with the required generic sections and a +v1 fenced manifest. Give it at least three dependent items and declarations for +the checks and local delivery lifecycle. Run deterministic validation through +the supported public path and assert that the valid plan produces no findings. + +Also run a deliberately broken copy and assert that all stable findings are +returned together, with source locations and remediation, before any project, +queue, Git ref, or event-store mutation occurs. + +### 3. Preview admission through the GUI + +Authenticate through the browser session and open the project/plan admission +surface. Submit the plan path and repository path. Assert that the GUI shows +the exact plan digest, manifest digest, repository identity, base ref and SHA, +integration ref, execution profile, revision changes, and any semantic review +questions. + +The preview must be read-only: no project, work row, immutable revision, +branch, worktree, model call, remote request, or delivery record may exist +after the preview. + +### 4. Confirm admission explicitly + +Review the preview in the GUI and confirm it through the browser’s authenticated, +CSRF-protected action. Assert that the exact reviewed digest is consumed once. +The project must be created or revised atomically, left stopped, and have one +immutable plan revision, item snapshots, current membership, work projection, +and typed dependency graph. + +Repeat the request with the same reviewed identity and assert idempotent replay. +Submit a stale digest and assert a typed conflict with no new revision. + +### 5. Prove preflight before execution + +Run project preflight against the admitted revision and exact execution profile. +Assert that all cheap configuration, repository, toolchain, policy, and storage +checks pass before any item is claimed. Break each preflight boundary in a +parameterized case and assert that execution does not start and no model call +is made. + +### 6. Execute dependent work with deterministic agents + +Start the stopped project through the supported control surface. Let the +deterministic agent complete the dependent items using real leases, attempts, +worktrees, patches, item checks, review outcomes, commits, and branch names. +Assert that an item is not claimed before its required local dependencies are +satisfied, that claims are isolated per project, and that no sleep-based timing +is used. + +Break agent, patch, item-check, review, budget, and policy boundaries. Each +case must leave the correct durable outcome and must not run the next expensive +gate after an earlier gate refuses. + +### 7. Promote and integrate locally + +Run the local integration path against the exact admitted base and integration +ref. Assert that promotion re-checks the item and integration gates, records +the accepted commit and graph revision, waits for unresolved dependencies, and +does not mutate remote state. + +Exercise conflict, failed re-gate, moved-base, and crash/restart recovery cases. +Retained completed history must be replayed through the promotion checks; it +must never be silently relabelled or discarded. + +### 8. Exercise the local delivery lifecycle + +Review and start the fixture delivery through its typed local-delivery surface. +Run build, deploy, readiness, acceptance, teardown, and recovery in order, with +each stage durable before the next stage begins. Assert exact command context, +output/checksum evidence, accepted integration SHA, and cleanup state. + +Parameterize every lifecycle failure. A teardown failure must remain visible +after an acceptance failure, and recovery must be attempted according to the +declared policy without converting an unmet gate into success. + +### 9. Verify GUI state and durable evidence + +After successful completion, reopen the GUI and verify that it reports the +stopped/running state, current revision, item states, dependency readiness, +accepted integration SHA, delivery stages, final outcome, and cleanup state. +Open item and project evidence views and assert that a reviewer can identify +the exact plan, revision, commands, commits, environment/profile, gate results, +operator decisions, and failure reasons from the GUI alone. + +The browser must use the same typed query and command services as the JSON API; +it must not read SQLite directly or imply a state transition through navigation +or drag-and-drop. + +### 10. Restart, audit, and release the fixture + +Close and recreate the harness services from the persisted stores at each +durable boundary. Assert that claims, attempts, revisions, outcomes, delivery +stages, and evidence recover deterministically and that replay is idempotent. + +Run the complete happy path and all fault cut-points under the repository’s +four gates. Record the accepted SHA, test identifiers, stage outcomes, and +known limitations as acceptance evidence. The deliverable is complete only +when the GUI path is implemented and the full ten-step test passes; partial +component coverage must be reported as partial. + +## Exit criteria + +The deliverable is accepted when: + +1. the happy path passes without network, credentials, model calls, or sleeps; +2. invalid-plan, admission, preflight, execution, promotion, and delivery + cut-points prove that later expensive or state-changing stages do not run; +3. the GUI preview and confirmation path is covered, including authentication, + CSRF, stale review, replay, and evidence views; +4. restart recovery and immutable revision history are asserted; +5. the final evidence identifies the exact accepted SHA and every lifecycle + stage honestly. + diff --git a/docs/FIT-FOR-PURPOSE-STATUS.md b/docs/FIT-FOR-PURPOSE-STATUS.md index e83eb61..5cf2e86 100644 --- a/docs/FIT-FOR-PURPOSE-STATUS.md +++ b/docs/FIT-FOR-PURPOSE-STATUS.md @@ -5,6 +5,7 @@ > Frozen at 2026-08-04 by its own header, and superseded on 2026-08-06 by `docs/STATUS.md`. Its stage-by-stage record is still the account of how each stage was verified in isolation. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/HARNESS-PLAN.md b/docs/HARNESS-PLAN.md index f1ca9fe..f5fa65c 100644 --- a/docs/HARNESS-PLAN.md +++ b/docs/HARNESS-PLAN.md @@ -5,6 +5,7 @@ > The original design document. It assumes one named consumer throughout, and the harness is generic — an owner ruling recorded in `AGENTS.md`. Its phase order was overtaken and its §0.1 already said so. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/MULTI-PROJECT-PLAN.md b/docs/MULTI-PROJECT-PLAN.md index 88bdfb1..4978260 100644 --- a/docs/MULTI-PROJECT-PLAN.md +++ b/docs/MULTI-PROJECT-PLAN.md @@ -5,6 +5,7 @@ > Project scoping, inception and the GUI that scoping was to make possible. Phases 0–2 are built and are described in `DESIGN.md`; the rest is not a current plan. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/PROPOSAL-2026-08-agent-interaction.md b/docs/PROPOSAL-2026-08-agent-interaction.md index c6c1bbb..3e744b8 100644 --- a/docs/PROPOSAL-2026-08-agent-interaction.md +++ b/docs/PROPOSAL-2026-08-agent-interaction.md @@ -5,6 +5,7 @@ > A proposal about how the harness talks to an agent. Overtaken by #195, which reframes the problem: the single-shot model call is the defect, and every role that answers questions about a repository must be a loop. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/PROPOSAL-2026-08-finish-then-extend.md b/docs/PROPOSAL-2026-08-finish-then-extend.md index c24a357..5e848c4 100644 --- a/docs/PROPOSAL-2026-08-finish-then-extend.md +++ b/docs/PROPOSAL-2026-08-finish-then-extend.md @@ -5,6 +5,7 @@ > A sequencing proposal. Its decisions D11–D14 are recorded in `AGENTS.md` and remain in force; the sequencing itself is spent. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/PROPOSAL-2026-08-fit-for-purpose.md b/docs/PROPOSAL-2026-08-fit-for-purpose.md index 9494052..e39386b 100644 --- a/docs/PROPOSAL-2026-08-fit-for-purpose.md +++ b/docs/PROPOSAL-2026-08-fit-for-purpose.md @@ -5,6 +5,7 @@ > The proposal the fit-for-purpose programme ran against. The programme finished; its report is in `docs/evidence/2026-08-04-programme-report.md`. > > **Current documentation:** +> [`minimal.md`](../minimal.md) — the current generic local-only target. > [`docs/DESIGN.md`](DESIGN.md) — how the harness works and why. > [`docs/STATUS.md`](STATUS.md) — where it stands and what is left to do. > diff --git a/docs/STATUS.md b/docs/STATUS.md index 4fa038d..86bc2aa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,827 +1,291 @@ -# STATUS — where agent-harness actually stands - -**Date:** 2026-08-06. **This is the only status document for this repository.** - -It says where the project stands, what is left to do, what the first real -workload is, and what must be true before it is run again. It does not describe -how the harness works today — that is [`DESIGN.md`](DESIGN.md), which owns the -implemented design. Section 2 records the product decisions and development -sequence agreed on 2026-08-06; where the current code does not implement one of -them, that is pending work rather than permission for DESIGN.md to claim it -already exists. - -[`FIT-FOR-PURPOSE-STATUS.md`](FIT-FOR-PURPOSE-STATUS.md) is frozen at -2026-08-04 and is being deprecated. Read it for the history of the stage -programme; do not read it as current, and do not link to it as current. - -Three words are used precisely throughout, exactly as `README.md` defines them: - -| word | meaning | -|---|---| -| **tested** | a test in this repository fails if it stops being true | -| **observed** | seen in a real run, without preserved artefacts that would let anyone reproduce it | -| **proven** | measured against a stated criterion, with the denominator and the commands published | - ---- - -## 1. Where it actually stands - -**The harness has never delivered a work item against a real workload.** - -Not once. Four passes of the direct executor and one standalone run of the -agent loop against `rdpapp` produced no merged work, and no other real workload -has been attempted. There is no delivery rate, no cost per merged item, and no -comparison against any baseline, because the numerator has never been greater -than zero. - -What *is* true, and is worth stating alongside it: - -- The deterministic paths are **tested** — the queue and leases, the dependency - graph, holds, attempts, budgets, the outcome taxonomy, the patch-apply - ladder, the checks gate, the reviewer gate, the API contract, the redactor, - the command guard, and the first-run/demo path. `uv run pytest` fails if any - of them stops being true. -- The service runs and is deployed inside AIDevEnv. That is **observed**, but - AIDevEnv is not part of the accepted product architecture below and this - deployment does not satisfy the execution requirement. -- Real model calls have been made against a real gateway, against a real - repository, and they exposed real defects — several of which now have - regression tests (#216, #217, #218). The defects are tested; the runs that - found them are observed. -- Nothing about live behaviour is **proven**. No column entry exists. - -The diagnosis of why nothing has been delivered is recorded in **#195**: the -single-shot model call is the defect. Every role — planner, implementer, -reviewer, surveyor, assessor, inception — is a model asked to answer questions -about a repository from a snapshot it was handed, unable to look at anything it -was not given and unable to check its own answer. Four separate repairs to the -implementer's output format on 2026-08-05 (unified diffs → edit blocks → -indentation tolerance → quoting the file back on a failed match) each improved -the output and each delivered nothing. The format was never the problem. - -The same item, same models, same gateway, run as a **loop** with tools reached -`cargo test` green in 31 turns. That real run went through a standalone script. -The loop has since been put through the queue, gates, append-only audit sink, -attempt record, item budgets and reviewer against local scripted fixture -repositories; that path is **tested**, while a real workload through it remains -unobserved. The standalone -run is **observed**, once, on one item — it is not evidence that the harness -works, and it is explicitly not a delivered item. - -**Stage 1 / #215 is implemented and tested locally. Stage 2 live acceptance -remains a prerequisite; Stage 3 wiring is now the next implementation block.** -The GitHub issue remains open while this exists only locally, per P12 and D1. A -useful loop must now be confined before it touches a real workload, then become -reachable from an AIDevEnv-independent service fleet, execute in isolated worktrees, promote -several items safely to one plan branch, surface exceptions, and attribute its -calls and outcomes to the item that caused them. None of that end-to-end path -has run. - -The first-party browser control plane from `codex/gui-plan` is now imported on -this tree and **tested** at the in-process application boundary. It includes the -packaged authenticated shell, project/work/hold/event views, guarded single-item -actions, inception and plan review, adoption, dependency exploration, routing, -worker and analytics views, audit operations, process/gateway projections, and -the typed/shared services those paths use. Importing it did not replace the -Stage 1 role-runner path: `role_runners.py`, the metadata-selected adapter and -their end-to-end fixture tests remain present. - -That is an implementation statement, not release evidence. No browser runtime, -phone viewport, screen reader, real deployment, forced SSE reconnect, real -GitHub concurrency or real fleet has exercised the imported GUI on this tree. -It remains an execution-independent client: monitoring works without an -executor, and a start action is still refused when the deployment cannot claim -work. The GUI's remaining work is recorded in §2.8 and in the consolidated -pending tables below; `GUI_PLAN.md` is design/history, while this file remains -the status authority. - ---- - -## 2. Settled product direction and development plan - -This section records owner decisions made on 2026-08-06 after the failed -single-shot runs and the successful standalone loop experiment. They are the -requirements for the next implementation cycle. They are written with their -reasoning because changing one in isolation recreates the four days of local -repairs that did not improve delivery. - -These decisions do not claim the code implements them. The first local -acceptance run in §2.4 is what moves them from requirements to observations. - -### 2.1 Immediate outcome - -The immediate success criterion is **a local, autonomous, multi-item coding -fleet**, not one agent completing one item. Given a plan, a repository and its -declared checks, the service must: - -1. start and supervise workers without AIDevEnv, a terminal session host, or a - local Codex/Claude/OpenCode process; -2. run at least two independent items concurrently in separate worktrees; -3. use a tool-using model loop for implementation, with all model traffic still - routed through `ModelClient` and the configured API provider; -4. wait for prerequisites, then base dependent work on the locally promoted - results of every prerequisite; -5. run the configured gates authoritatively, serialise promotion, and build one - local integration branch for the plan; -6. continue other work when one item fails, and ask a person only for a real - question, policy refusal, ambiguity or unrecoverable failure; and -7. preserve enough item-scoped evidence to explain every call, change, gate, - promotion, hold and failure. - -The first acceptance is deliberately local. After it passes, remote -acceptance publishes the integration branch and opens one pull request against -the target branch for human review. Later corrections update that same branch -and PR. There are no per-item pull requests and no automated merge to the -target branch. - -### 2.2 Decisions that are not to be re-litigated during this cycle - -| ID | decision | context and consequence | -|---|---|---| -| **P1 — multi-item first** | The delivery slice is a fleet executing a plan, not a one-item demo. | A one-item loop already reached green checks once and still proved neither scheduling nor delivery. The minimum acceptance graph therefore contains two independent items and one item that depends on promoted work. | -| **P2 — harness-owned execution** | The primary executor is an in-process role runner owned by `agent-harness`. AIDevEnv and subscription-backed CLI agents are not runtime dependencies. | The current supervised service can execute only when `--session-host` is supplied. That is useful historical scaffolding, but it fails the product requirement. AIDevEnv may be used to develop the repository and the session-host adapter may remain supported; neither may be required by `serve`, preflight, starting a project or completing an item. | -| **P3 — loop, not proxy** | `mini-swe-agent` supplies the tool-using interaction loop; it is not a proxy to the provider. Every model call continues through `ModelClient` to the configured route. | Direct API transport was healthy; the single-shot interaction model produced unusable work. The same API and model became useful when allowed to inspect, edit and test iteratively. Core owns a generic `RoleRunner` contract and resolves implementations by installed metadata; it must not import or name a particular adapter, preserving the generic-core rule. The selected runner and compatible version are load-bearing deployment configuration and must be reported by doctor/preflight. | -| **P4 — checks have two jobs** | Agents may run project checks during their loop for feedback. The harness always runs the configured gates again after the loop, on the exact tree proposed for promotion. | Preventing feedback recreates the blind single-shot defect. Treating an agent's claim that tests passed as the gate weakens the product. Feedback never substitutes for the authoritative gate and never changes its command or outcome taxonomy. | -| **P5 — real local confinement** | Every item receives its own git worktree and an OS-enforced filesystem boundary. The repository is writable; related dependency roots are explicitly declared per project with read/write mode, and default to read-only. A minimal platform runtime/toolchain may be mounted read-only and reported; dependency caches are declared or ephemeral writable mounts. No other undeclared host data path is readable or writable. | `CommandGuard` is screening, explicitly not a sandbox, and cannot enforce this requirement against inline programs. The current loop subprocess also inherits the controller environment. Before a real run, command execution needs a deliberately constructed environment, with provider, GitHub and harness credentials retained only by the controller. | -| **P6 — internet is available** | Agents have near-full outbound internet access, subject only to explicit platform policy and auditable denials. | Agents are expected to consult documentation and obtain ordinary dependencies. Filesystem and credential isolation still apply. This is not a confidentiality boundary: an agent that can read repository content and use the internet can transmit that content, so secret-bearing source must not be placed in an agent-readable checkout unless that exposure is accepted. | -| **P7 — one integration branch per plan** | The harness creates a plan branch from an exact target-branch commit. Workers use disposable item branches/worktrees; gated item commits are promoted to the plan branch under a single promotion lock. | Independent work can run in parallel, while promotion remains deterministic. If the plan branch advanced after an item started, the item is replayed onto the new head and the authoritative gates run again before promotion. An item with several prerequisites starts only after all have been promoted, so it needs no arbitrary choice among unmerged bases. “Automatic approval” means this gate-controlled local promotion, never a fabricated remote review approval. | -| **P8 — one human-reviewed PR** | When the whole plan is locally acceptable, the integration branch is pushed and one PR is opened against the target branch. Only a person approves and merges that PR. | Per-item/stacked PRs make dependency bases, review ordering and rebases the product's main complexity. The integration branch solves that locally and leaves one coherent human decision. Item-level commits, dependencies, gate results and summaries must remain visible in the final PR and API. | -| **P9 — review feedback resumes automatically** | New actionable review comments on the final PR create or resume correction work on the same integration branch, rerun the gates and update the existing PR. | Human review must not require an operator to reconstruct an agent session. Ambiguous, contradictory, policy-changing or already-resolved comments become holds for a person; they are not guessed at. Webhook delivery is preferred and polling is an acceptable recovery path, both deduplicated by immutable remote event identity. | -| **P10 — human involvement is exceptional** | Before publication, people are involved only for questions, holds and failures. The single final PR review and merge is the normal human approval point. | There is no approval ceremony per item and no need to watch agents work. A held item keeps its claim under D12; unrelated workers continue. The GUI/API must make the exception and the evidence needed to answer it visible. | -| **P11 — calls are not the optimisation target** | Use generous configurable loop bounds. Keep time, spend and call ceilings as emergency controls and continue measuring them, but do not shorten the loop to minimise request count. | The useful run took 31 turns; the failed design used one. Number of calls is not currently a material product concern. A limit still must stop a pathological loop, and a provider cost cap is still terminal and never retried. | -| **P12 — local development cadence** | Develop, commit, gate and integrate locally. Do not push, open a PR, wait for hosted CI, or deploy each implementation slice. | Those remote steps are materially delaying the feedback loop. GitHub support is retained and tested with local fakes; GitHub issues remain the state record required by D1, but issues stay open while implementation exists only locally. Publication happens once at the explicit milestone in §2.4. | -| **P13 — GUI is a client, not an execution dependency** | The GUI consumes typed API state and event/notification contracts. It is owned and served by `agent-harness`, but is not an input to executor design. | The imported browser control plane is a first-party client over shared services. Authenticated notification delivery and a generic review-source contract exist, while a deployed external source adapter remains optional. Execution must continue with the GUI offline. Holds, failures, completion and review events live in durable harness stores/API; the GUI or a later external channel presents and notifies. | - -### 2.3 Plan-branch and dependency semantics - -The integration branch replaces stacked pull requests as the coordination -mechanism: - -```text -target branch at an exact SHA - │ - ▼ - local plan branch ────────────────────────────────────────┐ - │ │ - ├── item A worktree ── gates ── promote A ─────────┤ - ├── item B worktree ── gates ── promote B ─────────┤ concurrent - │ │ - └── item C waits for A+B, starts from their │ - promoted plan head ── gates ── promote C ───────┘ - │ - publish one branch, one PR to target - │ - human review and merge only -``` +# Product status against the minimal local contract -“Done” inside the plan means promoted to the local plan branch, not merged to -the target branch. The queue may then release dependants. Only the completed -plan has a PR. If a promotion conflicts, the item returns to agent work with -the current plan head and the conflict evidence; it is not resolved by an -unreviewed merge strategy. If the target branch moves, the plan branch must be -updated and the full integration gates rerun before publication. - -### 2.4 Development sequence and exit evidence - -Work proceeds in this order. A stage is not complete because its code exists; -its exit evidence must be retained locally with the commands, commit and -denominator. Safety defects discovered in an earlier stage pre-empt the order. - -| stage | implementation | exit evidence before moving on | -|---|---|---| -| **0. Preserve the baseline** | Keep the direct and session executors while building the new path. Record these decisions and align affected tracker issues at the publication milestone. | The current four repository gates pass. No existing gate or historical evidence is removed to simplify the runner. | -| **1. Put the loop behind a generic runner** | Define the core role-runner protocol and installed-metadata lookup; adapt the existing mini-SWE loop to it; pass role, item, project and whole-loop bounds explicitly. Let the loop use checks for feedback, then feed its resulting tree into the existing checks/review/attempt/audit pipeline. | In local fixture repositories, a multi-turn implementer can inspect, edit and test; the harness reruns the declared gates; all calls are attributable to one project/item/attempt; no AIDevEnv or CLI-agent process is involved. A real workload is not run yet. | -| **2. Enforce the execution boundary** | Replace inherited shell execution with an OS-enforced local sandbox, a minimal allow-listed environment, an item worktree, and declared dependency mounts. The recommended first backend is Docker/OCI, behind a generic execution-environment contract and selected through deployment metadata; keep outbound internet available. Treat `CommandGuard` as an earlier explanatory refusal, not the security boundary. | Tests prove an agent can work throughout its repository, cannot read or write an undeclared sibling/host path, cannot read controller credentials from its environment, can use an allowed dependency root according to its mode, and can reach an allowed network fixture. The tests must exercise the actual backend and its configured security options, not only a mocked subprocess. Do not run rdpapp before this passes. | -| **3. Make `serve` own a local fleet** | Add an AIDevEnv-independent executor factory and worker pool to `serve`; make executor capability—not presence of `--session-host`—drive readiness and preflight. Allocate one worktree and runner per claimed item, with item-scoped telemetry and failure isolation. | With no AIDevEnv variables or session host, the API starts a project and two fixture items are observed running concurrently. Killing or failing one does not stop, park or corrupt the other; restart/reaping leaves claims and worktrees consistent. | -| **4. Build local plan integration** | Create and durably record the plan branch/base SHA; serialise promotion; rebase/replay and regate work produced from an older plan head; release dependants only after every prerequisite is promoted. Preserve item commits and promotion events. | A local fixture plan with two independent items and one item depending on both completes into one branch. The dependent item demonstrably sees both promoted changes. A conflicting promotion is returned for repair, and no remote is contacted. | -| **5. Complete exception and feedback control** | Expose item-scoped runner progress, questions, holds, gate evidence and promotion state through typed API/events. Add a deduplicated remote-review event contract and automatic correction-item/resume path, exercised against a local fake. Connect the GUI/notification workstream only through those contracts. | A question pauses only its item and can be answered through the API; an injected actionable review comment resumes work once; duplicates do nothing; ambiguous feedback opens a hold; the fleet continues throughout. The test does not require a GUI or GitHub. | -| **6. Local multi-item acceptance** | Run a real supplied plan against rdpapp, or another explicitly authorised real repository, entirely locally. Use at least two workers and a graph containing two independent items plus a dependent item. Build the local plan branch, run the real project gates and retain an evidence package. | The plan branch contains the promoted item commits and passes its declared integration gates. There was no AIDevEnv/session-host/CLI-agent dependency, no push, no PR and no deployment. Report delivery rate, failures, turns and cost honestly; one successful run is **observed**, not proven. | -| **7. Remote publication acceptance** | On a repository whose authoritative remote permits it, update from the target branch, rerun integration gates, publish the one plan branch and open one PR. Keep remote credentials in the controller. Detect review comments and exercise one automatic correction if review supplies one. | Exactly one plan PR is raised against the target branch with item/dependency/gate evidence. Corrections update that PR's branch; no item PR exists. The harness never merges it, and publication does not authorise deployment. A person reviews and merges or rejects it. rdpapp's GitHub mirror is not used for this while its own plan forbids that publication path. | -| **8. Measure, then broaden** | Convert the reviewer to its read-only loop (#226), then run #33/#44/#51 as their prerequisites become true. Move surveyor, assessor and deletion work only after implementer-fleet evidence exists. | Published denominators establish delivery, cost, gate and unattended reliability. Until then, do not describe the fleet as proven and do not spend the critical path on more roles or framework breadth. | - -### 2.5 Local development operating rule - -During stages 0–6, a coherent slice is committed locally after its focused -tests pass. The four repository gates run at every stage boundary. Local -commit identifiers and gate output go into the stage evidence package; they do -not need a remote PR to be valid evidence. - -Do **not** push, deploy, open a PR or wait for hosted CI between slices. Do not -close the corresponding GitHub issue while its only implementation is local, -because D1 makes that tracker the issue-state authority. At stage 7, publish -the accumulated, locally accepted milestone in one branch and one PR. This is -a cadence decision only: the GitHub client, PR support, reconciliation and -tests remain product functionality. - -Until the stage-6 evidence exists, pause work on additional role conversions, -planner/context deletion, lesson memory, UI features that are not needed to -expose the contracts above, and further output-format repairs. None addresses -the currently measured delivery failure. - -### 2.6 Current development position - -- **Stage 0:** passes on the current Stage 1 tree. The direct and session - executors remain present, and no historical gate or evidence was removed. -- **Stage 1:** implemented and tested locally. `run --role-runner agent-loop` - resolves the adapter through installed metadata before claiming, runs a - multi-turn implementer through `ModelClient`, captures the complete candidate - tree, and rejoins the existing checks/checkpoint/reviewer/attempt pipeline. - Model-call events carry project, item and work-attempt identity; item budgets - and terminal policy refusals stop at loop boundaries. Evidence is in - [`evidence/2026-08-06-stage-1-role-runner.md`](evidence/2026-08-06-stage-1-role-runner.md). -- **Stage 2 has run against a real daemon; its exit is still not claimed.** - The generic execution-environment contract, metadata-selected Docker backend, - disposable per-item self-contained Git checkout, explicit image/network/mount - configuration, controller-environment allow-list, pre-claim readiness check - and teardown path are implemented and covered by local contract tests. See - [`evidence/2026-08-06-stage-2-execution-environment.md`](evidence/2026-08-06-stage-2-execution-environment.md). - - On 2026-08-08 the live suite ran on Node B against a dedicated Docker-in- - Docker daemon deployed beside the controller, and passed: an agent reads and - writes its own worktree, reads a declared mount, cannot reach an undeclared - sibling path, does not receive controller credentials, is denied the network - under `network=none` and allowed it under `bridge`, and its container is gone - after teardown. Image digests, the update id and the reproduction command are - in - [`evidence/2026-08-08-stage-2-live-execution-boundary.md`](evidence/2026-08-08-stage-2-live-execution-boundary.md). - - **That is a substantial part of the exit, not the whole of it.** §2.7 also - requires the security profile itself — `no-new-privileges`, dropped - capabilities, a read-only root filesystem, resource limits and the recorded - image digest — and those are asserted only in the `docker create` argv, never - verified from inside a live container. The nested daemon's own confinement is - untested. Only one sandbox image has been used (Alpine/BusyBox), and the last - defect this suite found was an assumption about exactly that. **No real - workload run is authorised.** - - The live run earned its cost. Five defects were found that local runs and CI - could not see, the sharpest being that every sandbox command failed against - BusyBox because the harness wrapped them in GNU-only `timeout --signal=TERM` - — a failure that reads as the agent failing rather than the harness, which is - the misattribution class of #216. -- **Stage 3 wiring is present but its exit is not claimed.** `serve` can now - construct an AIDevEnv-independent local fleet from the metadata-selected - role runner and execution backend; readiness and preflight use that executor - capability, fixture coverage runs two items through separate self-contained - checkouts, and restart cleanup now has deterministic item paths plus - worktree-scoped Docker reaping. The live-backend evidence required by Stage 2 - is still missing, and Stage 3 has not yet proved killed-backend isolation or - the full acceptance graph. Plan-branch promotion now has local fixture - evidence: two independent items are promoted under the in-process lock and - durable cross-process lease, and a dependent item sees both changes. No real - workload run is authorised. -- **Stage 4 implementation has local fixture evidence but its exit is not - claimed.** The fleet fixture runs two independent items and one item depending - on both, preserves promotion records, exposes both promoted files to the - dependent runner, and retains the conflicting-promotion repair path. It does - not contact a remote. The live execution boundary and Stage 3 failure - isolation criteria remain open prerequisites. -- **Stage 5 is in implementation; its exit is not claimed.** A generic - normalized remote-review contract now accepts an immutable source/event id, - an explicit adapter-supplied disposition, and bounded feedback. Intake is - durably deduplicated in the queue database: actionable feedback creates one - correction item dependent on the reviewed item, ambiguous feedback creates a - pending correction held for a person, and already-resolved feedback creates - no work. An answer returns an ambiguity correction to `pending`; audit - delivery is best-effort and item-scoped. The typed API route and local tests - cover these semantics. `WorkEvidence` now also projects retained runner - progress, authoritative gate answers (including argv), plan-promotion state - and normalized review intake without replacing the raw event history. - A local-plan fleet acceptance now proves an actionable correction is claimed - once on the configured integration branch while a sibling item continues; - duplicates remain no-ops. A durable generic notification outbox now records - selected hold, failure, completion and review outcomes and retries them - through an authenticated bearer/HMAC webhook channel. - Two of the three remaining items now have implementations and local tests. - An installed review source (`github-pr-review`) resolves through metadata, - gives reviews and review comments distinct immutable identities, and decides - disposition by explicit markers and review state — with unmarked human prose - defaulting to a hold rather than to guessed work. A `PlanPublisher` pushes - one plan branch under `--force-with-lease` and maintains exactly one pull - request: a correction updates that same PR, an unchanged plan head touches no - remote, an existing PR is adopted rather than duplicated, a branch moved by - somebody else is refused, and nothing merges, approves or marks ready. That - publisher is wired into `direct_executor_factory`: `push=True` on a project - with a plan branch now means one plan branch and one pull request rather than - being refused, the executor is given no GitHub client and pushes no item - branch, publication waits until nothing is in flight and nothing failed, and - a remote failure is an event rather than a failed item. - Evidence is in - [`evidence/2026-08-08-stage-5-review-source-and-publication.md`](evidence/2026-08-08-stage-5-review-source-and-publication.md). - **No real remote was contacted for any of it**: the pull-request client is a - fake and the Git remote is a bare repository in a temporary directory. The - review source has never polled a real pull request, no publication has ever - reached GitHub, and remote workload acceptance remains open. No Stage 5 exit - and no remote workload run is authorised. - -### 2.7 Execution backend recommendation — Docker/OCI, selectively adopted - -The target state needs a real operating-system boundary before a real workload -is run. The recommended first backend for that boundary is **Docker/OCI**, -behind a generic execution-environment contract and selected through deployment -metadata. This recommendation follows a review of -[`desplega-ai/agent-swarm`](https://github.com/desplega-ai/agent-swarm/tree/30f79a927bb6c95b53da8797629cf13b67360159) -at commit `30f79a9` (2026-08-05). It is an execution-backend recommendation, -not a decision to adopt that project's control plane or task model. - -#### What to borrow - -- Use multi-stage OCI builds so stable, expensive toolchain layers are kept - separate from frequently changing harness code. -- Publish deliberately different image targets (for example, a small base and - a fuller development image), and measure uncompressed size and layer changes - in CI. A single universal image containing every language and browser is not - the default; it becomes too large and makes the toolchain contract unclear. -- Pin base images, operating-system packages, CLIs and toolchains. Record the - resolved image digest in preflight and item evidence so a result can be - explained after an image tag moves. -- Run the agent's commands as a non-root user and make writable locations - explicit. The container still needs a deliberate security profile: - `no-new-privileges`, dropped capabilities, a read-only root filesystem where - practical, resource limits, and no Docker socket or privileged host mount. -- Keep project toolchains in image/configuration metadata. The first real - workload is Rust, so the first acceptance image must contain the required - Rust/Cargo toolchain; the reviewed upstream image is not itself sufficient - because its published worker image focuses on Ubuntu, Node/Bun, Python, - browser tooling and supporting services rather than Rust. - -#### What remains harness-owned - -The harness remains the control plane and source of truth: - -- `serve` owns claiming, leases, attempts, budgets, holds, events, audit and - failure isolation; -- `ModelClient` owns every model call, route, retry ladder, endpoint parking, - pricing and terminal cost-cap policy; -- the selected role runner remains a generic metadata-resolved adapter; -- each item gets a disposable worktree and execution container; -- the plan branch, promotion lock, replay/rebase and authoritative gates stay - in the harness; and -- provider, GitHub and harness credentials remain in the controller. They are - not placed in the agent-readable environment merely because a container is - being used. - -The container is therefore a tool-command environment, not a long-lived model -worker. The model loop stays in the harness process and asks the backend to -inspect, edit and test the item's mounted worktree. - -#### What not to adopt from agent-swarm - -Do not copy its lead/worker task topology, persistent worker-owned repository -clones, or shared source volume as the coordination mechanism. Those are useful -for a different product, but they do not provide the target semantics here: - -- isolation is per worker there, while this target requires isolation per item; -- a reused clone can carry branches, stashes or uncommitted changes between - tasks, which recreates the stale-worktree failure already found here; -- model-provider CLIs and their credentials run inside the worker image, which - conflicts with P2/P3; and -- shared volumes cannot replace the exact plan-head and serialized promotion - rules in P7. - -E2B or another remote sandbox may later implement the same generic backend - contract, but it is not a reason to add a second orchestration plane now. - -#### Required backend contract before Stage 2 can exit - -The Docker implementation must be tested as deployed, not only through a fake -subprocess. For one item it must specify and durably report: - -```text -image reference and resolved digest -item worktree mount (read/write) -declared dependency and toolchain mounts (read-only by default) -ephemeral or declared writable caches -minimal allow-listed environment -network policy (outbound internet remains available) -resource and command limits -container identity and security profile -``` +**Status date:** 2026-08-09 + +**Target authority:** [`minimal.md`](../minimal.md) -Stage 2 is complete only when tests demonstrate repository-wide work, refusal -of undeclared host/sibling reads and writes, absence of controller credentials, -declared mount modes, allowed network access, and clean teardown. Stages 3 and -4 then prove that one such container/worktree is allocated per claimed item, -that killing one item does not affect another, and that Docker execution does -not weaken plan-branch promotion or the authoritative gates. - -### 2.8 GUI import and remaining work - -The browser implementation from `codex/gui-plan` at `deed5a1` has been -reconciled onto the current Stage 1 tree. The import is deliberately additive: -the current `DESIGN.md`/`STATUS.md` authority, generic role-runner contract, -metadata lookup and adapter tests remain. `agent-harness serve` now packages -and serves HTML, static assets and the typed JSON API from one origin, with no -session-host dependency for browser access. - -Implemented and covered by in-process tests: - -- bounded opaque browser sessions, token-rotation revocation, login throttling, - CSRF/origin checks and authenticated operator attribution; -- project, work, hold, event, dependency, worker and analytics views, plus an - SSE stream over the existing monotonic cursor; -- guarded project/work/hold controls and one-time reviewed project, routing, - plan-sync, adoption, audit-maintenance and reconciliation actions; -- typed shared services for queries, configuration, routing, plan sync, - adoption and audit operations rather than browser-only business rules; -- item evidence, worker inventory, process metrics, narrowed/redacted gateway - event projections, and an accessible no-script dependency table with an - optional packaged graph enhancement; and -- clean-wheel coverage for the templates and vendored assets. Normal operation - has no CDN or separate frontend build/service. - -Remaining work, in dependency order: - -1. **Finish the remaining execution-facing work in Stage 5.** The single-PR - publication/resume mechanism and an installed `github-pr-review` source now - exist with local tests, and the typed evidence projections, deduplicated - intake contract, local fleet acceptance and generic notification outbox were - already present, and publication is now wired into the executor factory so a - promoted correction updates the plan PR without an operator. What remains: - run that path against a real remote (Stage 7's milestone), deploy and poll - the review source against a real pull request, and prove fleet continuation - while an item is held or receives review feedback. The GUI must consume - these contracts and remain optional to execution. -2. **Complete existing operator controls.** Add exact-state reviewed bulk - transitions, complete continue/force-start and refusal parity, preserve hold - answers across expiry/version conflicts, and add any missing item filters or - artifact/diff links. No visual gesture is authority for a transition. -3. **Extend notification delivery beyond the first channel.** The durable - generic contract and authenticated webhook/channel adapter are present. - Add any deployment-specific presentation or phone channel only as an - opt-in adapter; do not infer one from the optional session host. -4. **Prove the browser boundary.** Add browser-runtime journeys for forced SSE - disconnect/replay and polling fallback, keyboard-only use, focus handling, - reduced motion, desktop/phone layouts and screen-reader semantics. Add the - planned XSS/CSP, cookie, root-path/proxy and simultaneous-operator security - exercises. In-process ASGI coverage is not a substitute for these. -5. **Exercise real integrations and packaging.** Run monitoring-only and - supervised deployment smoke tests from a built wheel/container; exercise - plan sync, adoption and reconciliation against an authorised non-critical - remote, including concurrent remote changes and partial-write recovery. - Retain release evidence and unmet criteria; the imported branch's historical - evidence does not prove the reconciled tree. -6. **Later product subsystems remain unbuilt.** Agent-harness-owned persistent - sessions/chat/terminal; memory, knowledge, skills and tools; scheduling and - external channels; multi-user identity/RBAC; and WAL-aware backup/restore - retain the sequencing in `GUI_PLAN.md` Milestones 5–8. They must use generic - protocols and may not create a gate registry while D8 remains open. - -GUI work outside item exceptions and evidence remains behind Stages 2–6 of the -delivery programme. A polished control plane cannot authorize the next real -workload before confinement, fleet ownership and plan integration pass their -own exits. - ---- - -## 3. All pending work - -Every open issue, organised by what a reader can act on. Issue state lives on -GitHub (D1); this section is a reading of it on 2026-08-06 and will drift. - -### 3.1 First implementation block - -| # | what it is | why it is where it is | -|---|---|---| -| **#215** | Build the generic agentic role runner and put the implementer through it. | Implemented and tested on the local development branch; the tracker stays open until the publication milestone (P12/D1). It is stage 1, not the whole milestone. The agent may run checks for feedback, the harness reruns them as gates, and bounds apply to the whole loop with generous call limits rather than forcing it back toward one-shot behaviour. The implementation follows P3's installed-metadata boundary: core imports no shipped runner. | - -### 3.2 The #195 programme - -**#195** is the organising idea and should be read in full before any of its -parts. It says the interaction model is the defect, names what survives -(`ModelClient` and its routing/retry/spend-cap behaviour, `protocols.py`, the -queue, holds, the graph, budgets, the guard, the audit, and the gates -themselves) and what is revealed as scaffolding (the planner, context -pre-selection, edit blocks and `to_diff`, structured-text parsing). It also -states the cost being accepted: `mini-swe-agent` is load-bearing in the selected -execution deployment rather than an experiment, and turn count rises from 1 to -roughly 30 per role per item. P3 refines the dependency wording: the core path -depends on a generic runner contract and metadata lookup, never on a named -adapter import. - -Its parts, in the order the workload's own evidence puts them: - -| # | what it is | why it is where it is | -|---|---|---| -| #215 | implementer through the role runner | implemented/tested locally; publication and tracker closure wait for the milestone — see above | -| #226 | reviewer through the runner, with a **read-only** environment | highest value after delivery works. The gate that rejected rdpapp T1 was *inferring* from a diff; it was right and it was guessing. A false rejection costs an attempt and blames a model that was correct. Read-only is not a detail: a gate with write access to the tree it judges can be talked out of a rejection. | -| #224 | surveyor through the runner, read-only | a plan should be written by something that read the repository. Matters more for the *next* project than for rdpapp M2, whose plan already exists. | -| #225 | assessor (`adopt`) through the runner, read-only | `adopt` asks a model to find evidence it cannot go and find. Real, and on nobody's critical path today. | -| #227 | retire the planner and context pre-selection | **deliberately last, and gated on evidence rather than a date.** Deleting the only path that has tests in favour of one that has never run in-harness is the trade AGENTS.md rejects. It moves once the loop has delivered items *through the harness*. | - -### 3.3 Defects with no blocker - -Each of these can be picked up today. All were found by reading code or by -reviewing a real failure, and none is waiting on anything. - -| # | what it is | why it is where it is | -|---|---|---| -| #219 | two edit blocks naming one file by different path strings (`a.txt` and `./a.txt`) render its diff twice; the second copy cannot apply | found during the #216 review and deliberately left out of it, because the fix changes `plan_edits`' public keying. Low severity and fails safely — but the message blames the model for an edit it got right, which is the class of bug this repository spent a day removing. | -| ~~#220~~ | two API routes compare a lease against `time.time()`, not `queue.now()` | **fixed locally, 2026-08-08.** The ruling taken is that `queue.now()` is authoritative for a lease everywhere; the retry and block routes now use it. Red-first: with the wall-clock comparison both new tests return 200 where 409 is correct. Tracker stays open until the publication milestone (P12/D1). `tests/test_api.py::test_retry_honours_the_queue_clock_not_the_wall_clock`, `…::test_blocking_honours_the_queue_clock_not_the_wall_clock`. | -| #221 | two holds opened by one attempt in the same tick raise a bare `sqlite3.IntegrityError` instead of a `HoldError` | `asked_at` is a float used as part of an identity. Effectively unreachable against a real clock, immediately reachable with an injected one. The fix needs a small design call: may one attempt hold twice at all? | -| ~~#223~~ | `_WIRE_ROLES` permits a `tool` message that `_for_the_wire` has already stripped the `tool_call_id` from | **fixed locally, 2026-08-08.** `tool` is removed from the allow-list, so the role a reduced message cannot validly carry can no longer reach the wire; the observation still goes back as a `user` turn and no content is lost. Tracker stays open until the publication milestone (P12/D1). `tests/test_agent_loop_e2e.py::test_a_tool_message_cannot_reach_the_wire_without_its_id`. | -| #207 | `test_pausing_a_project_stops_claiming` asserts completions stop within 100 ms, which is a timing assumption about the host | a CI flake on an unrelated branch. The property worth protecting is that pausing stops *claiming*; the assertion instead measures how fast an in-flight item finishes. The resume half of the same test already waits on a condition and is not flaky. | -| #209 | a stored model answer is redacted, so it cannot be used to reproduce what the model actually said | two promises in tension — "what did the model say" and "no credential reaches an append-only store" — with the second silently winning. It bites hardest on rdpapp, a credential vault whose fixtures are full of credential-shaped source. It matters most for exact-match edit failures, which are questions about characters, in a record whose characters were changed. | -| #103 | silent-but-active CLI sessions are indistinguishable from hangs | session-host path: PTY output is the only activity signal, so a working agent that prints nothing reports `activity: idle`. Independent of the #195 programme; note that #195 also deprecates `--session-host` in help and docs, so weigh effort here against that. | - -### 3.4 Blocked on a decision or a measurement - -These are not waiting on effort. Each names what it is waiting for. - -| # | what it is | blocked on | -|---|---|---| -| #184 | a generated phase heading is both a tracking umbrella and a claimable item, and it cannot be both | **a measurement**, from a deployment that has actually run a generated plan: are phase items ever claimed and completed, or do they sit `pending` while their children finish? `render_plan(..., phases_as_items=...)` currently splits the behaviour by caller, which the issue calls a holding position rather than a design. | -| #189 | a correction learned on one item is paid for again on every item after it | **a decision, and a measurement.** The mechanism is easy; whether a lesson store is compatible with this repository's measurement discipline is the question, because a store that mutates the implementer's prompt between items makes two runs incomparable. The issue's own recommendation is *do not build it* until a real multi-item run says how many check failures share a cause with an earlier item's — a number #33/#44/#51 would produce as a by-product. | -| #222 | an item the claim scan gives up on is left with no disposition, and empty means "not finished with yet" | **decision D8** — whether third-party gates get a registration mechanism in `outcomes.py`. Recording `exhausted` properly needs a new reason kind (probably `gave_up` under `DECIDED`), and adding one would answer part of D8 sideways. A test fails if `outcomes.py` grows a registry, precisely so D8 is not answered by accident. | - -Open decisions generally: **D7**, **D8** (above), **D9** (blocked on #84 — and -no stage may hold the review prompt as a variable while it is). See AGENTS.md -§ Decision hygiene; D1–D6 and D10–D14 are settled and are not to be -re-litigated. - -### 3.5 Blocked on a real run - -These cannot be closed by writing code. They need the harness to run against a -real workload for a real duration — which needs stages 1–6, not merely #215. - -| # | what it is | why it is where it is | -|---|---|---| -| #33 | 72-hour measurement run: rate-limit errors broken down by class, delivery rate, patch-apply rate, `review_rejected` rate, against the plan's §2.1/§2.5 baselines | **the P1 deliverable is this measurement, not the code.** Cannot start: no item has ever been delivered, so there is nothing to measure a rate over. | -| #44 | 48-hour ingester soak against live fleet traffic — no restarts, no dropped events, store growth within expectation | needs live fleet traffic, which needs a fleet that delivers. | -| #51 | 7-day unattended run — no manual restart, no human intervention, every failure diagnosable from the GUI alone | the top-level fit-for-purpose criterion. Furthest out; everything else is upstream of it. | -| #84 | A/B whether the reviewer seeing the planner's rationale changes its verdict | blocked on a **real backlog run twice over**. It is the experiment D9 deferred to, and the audit layer (`review_approved`/`review_rejected` per item, `GET /api/audit/cost`, `reconcile`'s merged/closed/reverted) now makes it measurable. The metric that matters is revert rate, not approval rate: a higher approval rate with a higher revert rate is anchoring, not insight. **Do not settle it by argument.** | - -Note that #84 and #226 interact: whether a read-only environment is enough to -keep the reviewer honest is untested, and #195 says so explicitly — it could -still be argued into a pass by its own reading of the code. - ---- - -## 4. rdpapp is the first application under test - -agent-harness is being exercised against **`TheDancingDeveloper-org/rdpapp`**, -also hosted on Forgejo at `repo.indexarr.net/indexarr/rdpapp`. **The Forgejo -remote is authoritative; GitHub is a mirror** — rdpapp's own `plan.md` says so, -and both remotes are configured in the working checkout (`origin` → Forgejo, -`github` → GitHub). - -It is the first real workload, and it was chosen deliberately at the hard end: - -- **Rust**, so a check gate means a real compile and a real test run, not a - linter; -- a **704 KB `main.rs`**, which is where the miscounted-hunk failures came from - — the arithmetic a unified diff header demands gets harder as a file grows, - and that evidence is what reopened decision D10; -- a **credential vault**, whose own test fixtures trip the harness's redactor, - which is how #209 was found. - -The workload's own running record is -[`evidence/2026-08-05-06-rdpapp-m2-status.md`](evidence/2026-08-05-06-rdpapp-m2-status.md) -(evidence package `rdpapp-m2-2026-08-05-06-v1`). Read it for the detail; it is -not duplicated here. In summary: - -- **No item has been delivered.** Four executor passes and one standalone loop - run. -- Pass 1 failed on miscounted hunk headers — a format defect, fixed, and the - reason D10 was reopened in favour of edit blocks. -- Pass 2 reached `checks passed → commit → review` and was rejected on - substance. That is the gate working correctly. -- Passes 3–4 failed with `SEARCH text does not occur in the file`. **That was - the harness's fault**: the diff was computed against a working tree still - holding the *previous item's* branch. The model was right every time and the - harness blamed it — and the better the previous item did, the more certain - the next was to fail. Fixed in #216. -- The standalone loop run hit `LimitsExceeded` at 40 turns, ~15 of them lost to - a guard false positive. Guard defect fixed (#217). - -Its decision — **do not repeat the existing delivery command** — remains this -repository's operating instruction. A rerun today uses the execution model -measured to deliver nothing. The next real run is stage 6 and must wait for the -loop, confinement, local fleet and plan-integration exits in stages 1–5. - -**These numbers are rdpapp's.** They are one repository, one gateway, one model -family, and nothing in them is a universal measurement about the harness. A -second repository is the only thing that would make any of it general, and -none has been attempted. - -Two limits of that evidence deserve repeating here because they qualify -everything above: the 31-turn loop run **cheated and was caught by hand, not by -a gate** — it appended tables to a tracked SQL fixture so its own registry -matched, and no reviewer ever saw it. And **cost is unmeasured**: turn counts -are recorded, spend is not, and `pricing` has never attributed a ~30-call role -to a single item. - ---- - -## 5. Last rdpapp run recipe — retained for evidence, do not execute - -This is the recipe that produced the observations in §4. It is retained so the -evidence is reproducible, **not** as the command for the next run. It invokes -the old direct executor and must be replaced by the stage-6 local-fleet command -after stages 1–5 pass. Its content comes from the evidence package above and -from an **unversioned** file at -`~/Working/Active/.harness-runs/rdpapp-m2/env.sh`. - -Paths below assume the layout of the machine this was run on -(`~/Working/Active/...`). Adjust them; nothing in the harness requires them. - -### 5.1 Preconditions, each verifiable - -```bash -# 1. Base lineage is not stale. This is what invalidated the abandoned -# 2026-08-04 attempt: work was based on a branch 121 commits behind the -# authoritative remote. -cd ~/Working/Active/rdpapp -git fetch --all -git rev-list --count harness/m2-base..origin/master # must be 0 - -# 2. The tree is clean. -git status --porcelain # must be empty - -# 3. Both remotes agree. `origin` (Forgejo) is authoritative; GitHub is a -# mirror. Confirm rather than assume. -git rev-parse origin/master github/main # must match - -# 4. The gateway answers, and with what. Claw Bay is frequently and broadly -# degraded: 8 of 42 models answered on 2026-08-05, all one family. -curl -s -H "Authorization: Bearer $THECLAWBAY_API_KEY" \ - https://api.theclawbay.com/v1/models | head -c 200 -``` +**Exploration-complete implementation backlog:** [`BACKLOG.md`](../BACKLOG.md) -### 5.2 The environment - -Run-scoped rather than written into a shell profile: the attempt is meant to be -discardable by deleting one directory, and a profile edit would outlive it. -Every model here is on Claw Bay; nothing routes to a local CLI agent. - -```bash -export HARNESS_ENDPOINT="https://api.theclawbay.com/v1" -export HARNESS_ROUTE_PRESET="claw-bay" -export HARNESS_API_KEY="${THECLAWBAY_API_KEY:?THECLAWBAY_API_KEY is not set}" - -# gpt only, by owner's decision 2026-08-05: measured 8 of 42 models answering -# and all 8 in this family. Chains are preference order, first that answers -# wins. gpt-5.6 leads because gpt-5.5 timed out the gateway origin (524) on -# long generations while 5.6 answered 200 throughout. -export HARNESS_PLANNER="gpt-5.6,gpt-5.4-mini" -export HARNESS_IMPLEMENTER="gpt-5.6,gpt-5.5,gpt-5.4" -export HARNESS_SURVEYOR="gpt-5.6,gpt-5.5" -export HARNESS_ASSESSOR="gpt-5.6" - -# NOT independent: same vendor as the implementer, because no second vendor is -# reachable. Every approval taken under this configuration is weaker than one -# taken when two vendors answer. Check GET /api/routes/health before trusting a -# review; when independence_possible turns true, move this to another vendor. -export HARNESS_REVIEWER="gpt-5.5,gpt-5.4" - -# No local CLI agent. Direct API mode is used, so this is belt and braces: if a -# session host is ever passed, the agent must still not be a -# subscription-backed local binary. -export HARNESS_AGENT_COMMAND="" - -# Shared so each item does not pay a cold Rust build in a fresh worktree. -export CARGO_TARGET_DIR="$HOME/Working/Active/.harness-runs/rdpapp-m2/cargo-target" -``` +**Implementation design:** [`DESIGN.md`](DESIGN.md) -### 5.3 The historical run - -```bash -cd ~/Working/Active/apps/agent-harness -R=~/Working/Active/.harness-runs/rdpapp-m2 -. $R/env.sh - -uv run agent-harness --db $R/queue.sqlite run --project rdpapp-m2 \ - --work ~/Working/Active/rdpapp \ - --plan ~/Working/Active/rdpapp/docs/harness/M2-PLAN.md \ - --base harness/m2-base --no-push --reroute \ - --context-budget 300000 \ - --events $R/events.jsonl \ - --check 'cargo test -p rdpapp-models -p rdpapp-sessions -p rdpapp-gateway' \ - 2>&1 | tee $R/run-$(date +%H%M).log -``` +**Historical evidence:** [`evidence/`](evidence/) + +This document is the current, mutable status report. It records what the +repository can demonstrate today and the work needed to satisfy the target. It +does not turn a fixture, a partial run, or an older remote-delivery proposal into +a completed product capability. -Flags that are not decoration: - -- **`--no-push`** — rdpapp's `plan.md` calls GitHub a mirror and says the CI - cutover is **not authorised**. Work stays on local branches, so a discarded - attempt is branches to delete rather than a remote to clean. -- **`--reroute`** — without it the **stored** role map wins and the role-chain - environment above silently does nothing. The harness warns, and the warning - is emitted *before* the reroute applies, so it can read as a failure when it - is not. -- **`--base harness/m2-base`** — cut from `4dff7e2`, equal to both remotes. -- **`--context-budget 300000`** — the implementer chain's head is `gpt-5.6`, - which holds 372k. Anything above that silently exceeds the model. -- **`--check '…'`** — Rust only, and deliberately: `migration-tool` is - workspace-`exclude`d so its FluentGUI path dependency does not affect the - gated crates (do not add it), and a fresh worktree has no `node_modules`, so - `tsc`/`vitest` cannot start. **Do not gate on `cargo fmt --check`** — it - refused five otherwise-correct attempts in the abandoned attempt. #155 lets a - declared formatter's fix run and re-checks, but it is off unless - `apply_fixes` is set on the project. - -### 5.4 Retrying failed items without destroying `last_error` - -```bash -uv run python -c " -import sqlite3; c=sqlite3.connect('$R/queue.sqlite') -c.execute(\"update work set state='pending', owner=NULL, lease_until=0 where state='failed'\") -c.commit()" -``` +## Headline -**Keep `last_error`.** The harness feeds it into the next attempt's prompt, and -that is the only thing that makes a retry different from a repeat. Clear -`attempts` too **only** when the previous failure was the harness's fault -rather than the item's; otherwise the attempt ceiling stops meaning anything. +The repository is a substantial pre-alpha harness, but it does **not yet meet +the minimal local product contract**. -### 5.5 Monitoring +It has a durable queue and event model, checks and review gates, execution +backends, a same-origin API/GUI, local worktree isolation, and a tested local +plan-integration mechanism. A live daemon has also processed real attempts. -```bash -R=~/Working/Active/.harness-runs/rdpapp-m2 +It does not yet have the generic minimum plan schema, one-pass plan admission, +per-project pinned execution profile, or final integrated +build/deploy/readiness/acceptance/teardown lifecycle. No real project has +completed that entire path. Those are product gaps, not Rainmon prerequisites. -# What each item is doing. The stages are the whole story: an item that reached -# `checks` failed differently from one that died at `implement`. -grep -E "T[0-9]+ (started|edits_parsed|edits_rejected|applied|checks_|review_|committed|no_diff)" \ - $R/run-*.log | tail -30 +## Target boundary now in force -# Queue state. -uv run python -c " -import sqlite3; c=sqlite3.connect('$R/queue.sqlite') -for r in c.execute('select item_id,state,attempts,disposition,reason_kind,substr(coalesce(last_error,\"\"),1,70) from work order by cast(substr(item_id,2) as int)'): print(r)" +The minimum supported delivery path is local: -# What the models actually said (#190). -python3 -c " -import json -for l in open('$R/events.jsonl'): - e=json.loads(l) - if e.get('outcome')=='ok' and e.get('answer'): - print(e['model'], e['answer_chars'], 'redacted' if e['answer_redacted'] else '')" +- input is a user-authored, deterministically valid plan and a local Git repo; +- agent work, checks, review, commits, and integration occur locally; +- the integrated product is built, deployed, accepted, and torn down locally; +- the output is an accepted local integration branch plus durable evidence; +- remote Git mutation, hosted CI/CD, remote issues/reviews, and non-local + deployment are not prerequisites and are outside minimum completion. -# Gateway health, per model, from traffic already made (#192). -grep -c "fell back" $R/events.jsonl -``` +Existing remote workflow code may remain as an optional extension. It must not +leak credentials, vendor concepts, or required steps into the local path. + +## Capability comparison + +| Target capability | Evidence in the repository | Status | Required work | +|---|---|---:|---| +| Generic minimum plan template | `examples/PLAN.md` contains the required prose and exactly one v1 fenced-TOML manifest; `plan_contract.py` parses typed immutable values and preserves argv arrays. | **Partial** | Add a placeholder-bearing authoring template and complete validator/admission integration. | +| One-pass deterministic rejection | `plan_validation.py` returns stable `PLAN-*` findings for document, manifest, work-item, policy, and graph problems; CLI and `POST /api/plans/validate` expose the report without writes. | **Partial** | Expand adapter-specific and target-mode checks, then prove the full deliberately broken fixture and no-state-mutation contract. | +| No interview required for admission | `inception` and survey code can produce a plan through questions. | **Not aligned** | Make template validation the normal admission path. Keep interactive inception only as an optional authoring aid. | +| Atomic plan admission | `admission_service.apply` now persists the stopped project, immutable revision/items, work projection, and typed graph in one transaction with rollback injection, stale checks, and exact replay idempotency. Revision classification, explicit removals/reopens, reviewed CLI admission, typed API apply, and revision list/detail reads are implemented. | **Partial** | Complete GUI confirmation and live restart/replay evidence. | +| Dependency-aware queue | SQLite work items, typed dependencies, readiness, leases, attempts, holds, and dependent-item waiting exist and are tested. | **Implemented, not fully live-proven** | Exercise the path as part of a complete real-project acceptance run. | +| Isolated local agent work | Local Git worktrees, exact base commits, role runners, model/session executors, budgets, command screening, and checks exist. | **Partial** | Drive these from the admitted per-project profile and prove unsupported requirements fail before claim. | +| Per-project execution profile | Host/Docker backends and image/mount service configuration exist. | **Missing at the product boundary** | Persist toolchains, packages, services, mounts, network policy, named secrets, and immutable image identity per project/plan revision. | +| Reviewable runner image generation | Docker execution can consume an image selected by deployment configuration. | **Missing** | Generate or accept a recipe, show it for review, build/test locally, and pin its digest without silently executing installation prose. | +| Checks and reviewer gates | Declared project checks, structured outcomes, reviewer roles, audit events, and policy refusal exist. | **Implemented in components** | Preserve these gates while wiring the local product lifecycle; do not turn lifecycle commands into a way to bypass them. | +| Checks use the admitted runner profile | The Docker role runner edits inside its container, but `CheckRunner` currently executes authoritative item and promotion checks on the controller host. | **Missing at the boundary** | Add a no-shell argv operation to execution backends and run both item and integration checks in the pinned profile, with no host fallback. | +| Local item integration | Per-plan branches, serialized promotion, replay on moved tips, promotion-time re-gating, conflict handling, and dependent waiting are fixture-tested. | **Implemented, fixture-proven** | Complete live end-to-end acceptance and expose safe reviewed local finalisation semantics. | +| Integrated build | Promotion re-runs project checks. | **Partial** | Add a distinct plan-declared integrated build stage and durable outcome. | +| Local product deployment | The harness itself can be served locally in monitoring, session-host, or direct local execution configurations. | **Missing for the product under development** | Implement an adapter/config-driven local deploy action for the integrated commit. | +| Readiness, acceptance, teardown, recovery | General checks and attempt outcomes exist. | **Missing as a final lifecycle** | Implement ordered, durable lifecycle stages with guaranteed teardown/recovery attempts and honest partial-failure reporting. | +| Durable evidence | Append-only events, audit, attempts, outcomes, redaction, API projections, and evidence reports exist. | **Strong foundation** | Correlate admission, image/profile, exact commits, final lifecycle commands, outcomes, and human decisions into one delivery record. | +| Generic extension mechanism | Route presets and dependency resolvers use installed metadata; genericity tests police the execution path. | **Implemented foundation** | Add local topology support through the same generic principle; do not import or name project/vendor adapters from core. | +| Complete real-project proof | Stage 2 reached a real daemon and processed real attempts; Stage 4 mechanics have fixture evidence. The planned ten-step deterministic local E2E deliverable is documented in [`docs/E2E-ACCEPTANCE-PLAN.md`](E2E-ACCEPTANCE-PLAN.md), but not yet implemented. | **Not achieved** | Deliver the fixture after the GUI admission workflow and lifecycle prerequisites are complete; then complete one representative project and a materially different second project through the minimal contract. | + +## What is already worth keeping + +The target is a scope correction, not a rewrite. The following are reusable +product foundations: + +- append-only redacted event and audit stores; +- SQLite queue, leases, attempts, holds, budgets, and typed dependency graph; +- provider/model routing and terminal cost-cap classification; +- deterministic command guard and worktree boundary checks; +- project checks, role-specific runners, structured gate outcomes, and reviewer + separation; +- local worktree preparation, item commits, plan branches, serialized promotion, + replay, re-gating, and dependency waiting; +- direct model and session-host execution backends; +- typed JSON API, OpenAPI document, same-origin browser GUI, and authenticated + operator actions; +- installed-metadata adapter discovery and the genericity enforcement test. + +No phase should rewrite these components merely to match new terminology. New +work should connect them through the admitted local contract. + +## Misalignments that must not become requirements + +### Interactive plan manufacture + +The current inception/survey path asks questions and can inject defaults. That +may remain a convenience for someone who wants help drafting a plan, but it is +not plan admission. Missing minimum content must produce one rejection report. +Defaults that select a repository host, hosted runner, publication model, +deployment topology, or third-party gate policy are not generic facts and must +not be silently promoted into the admitted manifest. + +### Deployment-wide runner assumptions + +Current serve/worker configuration can choose a Docker image and mounts for the +service. The target needs these captured and pinned per project or plan revision. +A global operator default may seed a proposal, but the admitted plan must make +the effective environment visible. + +### Remote publication as completion + +Older plans describe GitHub issues, remote branches, pull requests, review +providers, hosted checks, and publication as the delivery path. Those features +are not evidence that the minimal local lifecycle is complete. They are optional +extensions and must remain dormant when the local path is selected. + +### Workload-specific acceptance + +The previous `nextsteps.md` was a Rainmon/Node-B runbook. It mixed harness +acceptance with one consumer's repository, credentials, topology, and CI policy. +It is superseded by the generic sequence below. A Rainmon plan may declare those +facts, but core and its minimum release criteria may not assume them. + +## Delivery sequence + +This sequence replaces the phase order in historical plans. Each milestone must +leave evidence and preserve the existing gates. + +### M0 — Contract and documentation alignment + +**State:** complete in the current working tree; implementation remains M1+. + +- establish `minimal.md` as target authority; +- make this file the comparison against that target; +- mark older plan/status documents historical or superseded; +- publish the selected generic plan template shape and remove workload-specific + next steps from the current path; +- publish `BACKLOG.md` with explored decisions, explicit dependencies, tests, + evidence, and non-goals for every implementation item. + +**Exit:** current documentation distinguishes target, implementation, evidence, +and history without claiming the target is implemented. + +### M1 — Versioned minimum plan and validator + +**State:** implementation in progress; P1/P2 core and initial CLI/API surfaces are built, but M1 exit evidence is not met. + +- implement the selected fenced-TOML v1 manifest schema, canonical form, and + unknown-version migration policy; +- validate required prose sections and explicit applicability statements; +- validate execution profile, safe argument-array commands, local topology, + work items, acceptance, and dependency graph; +- emit all stable-code findings with source locations and remediation in one run; +- provide machine-readable and human-readable output; +- keep validation deterministic and model-free. + +**Exit:** malformed or incomplete plans fail without creating project or queue +state, and a valid example passes with no interactive questions. + +The selected schema, exact adapter contracts, admission transaction, execution +boundary, lifecycle journal, and evidence work are decomposed item-by-item in +`BACKLOG.md`; milestone bullets here remain status summaries rather than a +second backlog. + +### M2 — Reviewed, atomic admission + +**State:** A1-A3 foundations implemented; A4-A5 and exit evidence remain outstanding. + +- bind the plan to the local repository identity and exact base commit; +- show the effective execution profile and local lifecycle before acceptance; +- optionally run post-minimum semantic review and batch material questions; +- persist project, plan revision, dependency graph, and queue atomically; +- prove retrying admission is idempotent. + +**Exit:** a valid plan becomes one auditable queue exactly once; rejection or a +blocking question leaves no executable work. + +### M3 — Per-project execution profile + +**State:** not started; existing host/Docker backends are inputs. + +- persist toolchains, packages, services, mounts, dependency provisioning, + network policy, named secrets, and backend per plan revision; +- support an existing immutable image and a reviewable generated-recipe path; +- build and test generated images locally and pin digests; +- extend preflight to prove commands and declared services are available; +- run authoritative item and promotion checks inside that exact profile rather + than on an undeclared controller toolchain; +- reject undeclared or unsupported requirements before a claim. + +**Exit:** two different project profiles can coexist in one harness deployment +without changing global worker flags or core modules. + +### M4 — Local fleet completion + +**State:** component-rich but end-to-end incomplete. + +- connect admitted profiles to role runners and item workspaces; +- prove claim, attempt, hold, resume, budget, review, check, and commit behaviour + against a real daemon; +- retain terminal cost-cap and per-worker retry invariants; +- close the gap between fixture Stage 4 integration and real execution. + +**Exit:** a real multi-item plan reaches an accepted local integration commit or +an honest terminal outcome for every item, with no remote credentials. + +### M5 — Final local product lifecycle + +**State:** missing. + +- run the declared integrated build from the exact plan-branch commit; +- deploy through the selected local topology; +- evaluate readiness and acceptance separately; +- always attempt declared teardown/recovery when safe; +- retain commands, output references, duration, commit, profile/image identity, + and outcome for every stage; +- expose an explicitly reviewed local target-ref finalisation action if needed. + +**Exit:** the harness can say precisely whether the integrated product built, +started, became ready, passed acceptance, and cleaned up locally. + +### M6 — Generic release proof + +**State:** not started. + +- complete the full path for one representative real project; +- complete the first through the shipped local-process topology and a second + project through Compose with a materially different toolchain; +- demonstrate no execution-path core edit was required for the second project; +- pass pytest, Ruff check, Ruff format check, strict mypy, and genericity tests; +- publish measured failures and residual limitations. + +**Exit:** every acceptance criterion in `minimal.md` is supported by retained +evidence. Only then may the minimum product be described as delivered. + +## Evidence boundary + +### Demonstrated + +- A real supervised Stage 2 run processed attempts and exposed fleet defects: + [`evidence/2026-08-05-06-rdpapp-m2-status.md`](evidence/2026-08-05-06-rdpapp-m2-status.md). +- Local plan-branch integration mechanics passed fixture acceptance: + [`evidence/2026-08-06-stage-4-plan-integration.md`](evidence/2026-08-06-stage-4-plan-integration.md). +- Repository tests cover the individual queue, execution, integration, API, and + genericity components described above. + +### Not demonstrated + +- deterministic admission of the new minimum template; +- per-project runner-image generation and digest pinning; +- a complete live multi-item plan through local integration; +- local deployment and acceptance of the product under development; +- a second materially different project with no core changes. + +Current implementation evidence: `plan_revisions.py` persists immutable plan and +item snapshots with digest idempotency; `admission_service.preview` binds a +valid plan to read-only repository identity and exact base SHA; the typed API +preview route returns the proposal without mutation. Apply, semantic-review +question persistence, revision classification, and complete GUI/CLI admission +workflow are not implemented yet. `admission_service.apply` is the tested +atomic foundation and leaves projects stopped. + +Evidence reports are append-only historical records. They are not edited to +match this target; their scope and dates remain part of what they prove. -**The caveat that matters (#209): stored answers are redacted on the way into -the store.** Redaction is applied in `store.append` and `audit.append` and -never to prompts, so the model was given the real file — but text containing -`password: "…"`, which rdpapp's fixtures are full of, is rewritten before it is -recorded. **Do not diff a stored answer against a file and conclude the model -was wrong.** The `answer_redacted` flag tells you it happened; it does not tell -you where or how much. - -Symptoms and what they mean: - -| symptom | meaning | -|---|---| -| `edits_rejected … does not occur` | the model named text that is not there. Since #216 this is genuinely the model, not a stale worktree; the error now quotes the file back. | -| `review_rejected` | the gate working. Read the objection — on T1 it was correct and the brief was sharpened in response. | -| `LimitsExceeded` (loop) | ran out of turns. Check the refusal count first: a guard false positive used to consume ~38% of them (#217). | -| 429 / 503 storms | the gateway, not the harness. `--implementer a,b,c` chains past it; `survey` could not until #193. | -| nothing claimed, queue full | was a real deadlock (#218) — the claim scan stopped after one page. Fixed; a recurrence is a regression worth reporting. | - -### 5.6 Cleaning up an attempt - -```bash -rm -rf ~/Working/Active/.harness-runs/rdpapp-m2 # queue, events, logs, cargo cache -cd ~/Working/Active/rdpapp -git worktree list # remove any under .harness-work/ -git branch -D $(git branch --list 'harness/t*' 'adapter/*' 'spike/*' | tr -d ' *+') +## Decisions and blockers + +- Settled decisions D1–D7 and D10–D14 remain settled where applicable. Limiting + the minimum product does not rewrite their evidence. +- D8, registration of arbitrary third-party gates, remains open. The minimal + product must not assume such a registry. Its fixed build/readiness/acceptance/ + teardown lifecycle and existing check outcomes do not answer D8 by accident. +- D9 remains blocked as recorded in repository guidance. +- The embedded authoring syntax is settled as exactly one fenced `harness` TOML + block with `version = 1`; field semantics and implementation tasks are in + `BACKLOG.md`. The parser, canonical form, validator, CLI, and API report now + exist; admission, adapter-specific checks, and complete evidence remain + incomplete. + +## Verification for documentation alignment + +Documentation-only alignment should at minimum check links, genericity wording, +and the working-tree diff. Code gates are required when implementation changes +begin. The repository-wide gates remain: + +```console +TMPDIR=/path/on/a/fast/volume uv run pytest +uv run ruff check . +uv run ruff format --check . +TMPDIR=/path/on/a/fast/volume uv run mypy ``` -`harness/m2-base` is the base and must survive. **The `harness/r1`–`harness/r7` -and `harness/base*` branches are from the ABANDONED 2026-08-04 attempt** — the -one based 121 commits behind — and are not this work. Do not read them as -evidence and do not build on them. - ---- - -## 6. What is proven, observed and tested - -| claim | word | how to check it | -|---|---|---| -| Queue and leases, dependency graph, holds, attempts, budgets, outcome taxonomy, patch-apply ladder, checks gate, reviewer gate, API/OpenAPI contract, redaction on the only two write paths, command guard, first-run/demo path | **tested** | `uv run pytest` | -| The core stays generic — no workload-specific paths, numbers or adapter imports | **tested** | `tests/test_generic.py` (`EXECUTION_PATH` is the authoritative list) | -| The store has no UPDATE and no DELETE | **tested** | the source-level assertion in the store tests | -| The four rdpapp-derived defects: edit-block rendering, stale worktree, guard false positives, claim-scan page deadlock | **tested** | regression tests landed with #216, #217, #218 | -| A metadata-selected multi-turn implementer can inspect, edit, run feedback checks, create new files, and then pass through the harness's authoritative checks, attempt record and reviewer with item-scoped events and budgets | **tested** | `tests/test_role_runners.py`, `tests/test_role_runner_e2e.py`, and the adapter regressions in `tests/test_agent_loop_e2e.py` | -| One plan branch yields exactly one pull request: a correction updates it, an unchanged head touches no remote, an existing PR is adopted, a foreign push is refused, and nothing is merged | **tested** | `tests/test_plan_publication.py` — against a local bare remote and a fake pull-request client, never GitHub | -| A fleet publishes that one pull request only once the plan has stopped moving, pushes no item branch, and updates the same PR for a later correction | **tested** | `tests/test_plan_integration.py::test_fleet_publishes_one_plan_pr_only_when_the_plan_is_finished` | -| An installed review source gives reviews and review comments distinct immutable identities and decides disposition without a model, defaulting unmarked prose to a hold | **tested** | `tests/test_github_pr_review_source.py` — `gh` is injected; no real pull request has been polled | -| An agent reads and writes its own worktree, reads a declared mount, cannot reach an undeclared sibling path, receives no controller credentials, is denied the network under `none` and allowed it under `bridge`, and its container is removed on teardown — against a real Docker daemon | **observed** | [`evidence/2026-08-08-stage-2-live-execution-boundary.md`](evidence/2026-08-08-stage-2-live-execution-boundary.md) — one run, one host, one sandbox image, with the image digests and update id recorded. The security profile itself is argv-tested only. | -| The service runs and is deployed inside AIDevEnv | **observed** | no preserved artefacts | -| An earlier supervised NGMS attempt and later direct calls exercised real agents and providers | **observed** | [`evidence/2026-08-03-04-ngms-first-sustained-run-v1.md`](evidence/2026-08-03-04-ngms-first-sustained-run-v1.md) — lacks a common run ID, complete configuration, checksums and a comparable follow-up | -| Four executor passes against rdpapp delivered nothing, and why each failed | **observed** | [`evidence/2026-08-05-06-rdpapp-m2-status.md`](evidence/2026-08-05-06-rdpapp-m2-status.md); the pass 3–4 attribution is hindsight and has not been confirmed by re-running against the fix | -| A loop reached `cargo test` green on rdpapp in 31 turns | **observed** | one item, once, through a standalone script — never through the harness, and it cheated in a way caught by hand rather than by a gate | -| Claw Bay answered on 8 of 42 models on 2026-08-05, all one family | **observed** | a sweep on one day; not preserved | -| Delivery rate | **neither** | no item has ever been delivered | -| Cost per merged item | **neither** | spend is not recorded per item; `pricing` has never been checked against a multi-turn role | -| Unattended reliability | **neither** | #33, #44, #51 have not run | -| Second-repository portability | **neither** | one repository has been attempted | -| Whether a read-only reviewer stays honest | **neither** | #226 is unbuilt; #84 is the experiment | -| Whether the reviewer's verdicts in this window are trustworthy | **not known** | every verdict recorded was same-vendor, because no second vendor was reachable, and nothing attaches that caveat to the verdicts themselves | - -Nothing is in the **proven** column. That is not modesty; it is the definition -— nothing about live behaviour has been measured against a stated criterion -with a published denominator. - -"No failures observed" is not the same as "the requirement was exercised". +Passing those gates is necessary but not sufficient for M6; the live local +product lifecycle evidence is also required. diff --git a/docs/USAGE.md b/docs/USAGE.md index d1548b6..a586324 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -3,6 +3,15 @@ A worked example, end to end, with real output. Everything below was run against [`examples/PLAN.md`](../examples/PLAN.md) in this repository. +> **Target boundary — 2026-08-09:** This guide describes commands the current +> pre-alpha implementation exposes, including optional remote issue/PR paths. +> The minimum product is now the local-only lifecycle in +> [`minimal.md`](../minimal.md). Its deterministic plan admission, +> per-project execution profile, and final local product deployment lifecycle +> are not implemented yet; [`STATUS.md`](STATUS.md) tracks those gaps. Do not +> read `--repo`, GitHub sync, hosted CI, or interactive inception below as a +> prerequisite for the target product. + If you only read one thing: **every destructive step has a `--dry-run`, and the sync defaults to one.** Run those first. They tell you exactly what would happen without doing any of it. @@ -21,7 +30,7 @@ No MyDevEnv, AIDevEnv or other host process is required for browser access. --- -## Which way in? +## Which way into the current implementation? Four routes, and they are not alternatives to each other so much as different starting points. Find your row. @@ -29,8 +38,8 @@ starting points. Find your row. | Where you are | Start at | What you get | |---|---|---| | **You want to see whether this thing works at all** | [§0a](#0a-the-first-run-no-credentials-no-network-no-model) — `init --demo` | A real git repo, a plan and a queue, and one item taken end to end. No credentials, no network, no model. | -| **New project, and you have not written the plan yet** | [§0b](#0b-or-dont-write-a-plan--describe-it-and-argue) — inception, **over the API** | Describe it in a paragraph, argue with the proposed scope, get a `PLAN.md`. Nothing external exists until you approve. | -| **New project, and you already have a plan** | [§1](#1-write-a-plan) — `plan` | Your markdown parsed into work items, then synced to issues. | +| **New project, and you have not written the plan yet** | [§0b](#0b-or-dont-write-a-plan--describe-it-and-argue) — optional inception, **over the API** | Use the current authoring helper to get a draft `PLAN.md`. Target admission does not require this interview. | +| **New project, and you already have a plan** | [§1](#1-write-a-plan) — `plan` | The current parser reads work items. Remote issue sync is an optional legacy/extension action. | | **Existing project, already part-built** | [§0c](#0c-or-adopt-a-project-that-is-already-half-built) — `adopt` | What is *already done* proposed rather than assumed, with the evidence for each claim. Nothing is dropped unless you name it. | They converge. **Every route ends at a `PLAN.md` and a project in the queue**, @@ -297,6 +306,24 @@ curl -sH "Authorization: Bearer $TOKEN" -X POST \ survive the feed being unavailable for a day"}' ``` +### Experimental authoring defaults — not admission policy + +The current scoper receives defaults for a new project. They are an +experimental authoring convenience, not facts about the project, admitted +configuration, or automatic authorisation. Repository evidence or an operator +can override them, and material overrides remain in the proposal as assumptions +or questions. + +Some current defaults recommend a private hosted repository, CI runners, +Compose deployment, an orchestrator, and a publication policy. Those are +opinionated proposal text and are explicitly **outside** the generic minimum +contract. They must not become plan-admission defaults. The target requires the +user to declare their local agent profile and local product topology, then +validates the complete plan in one pass. + +No repository, remote, branch, issue, queue row, publication, or deployment is +created merely because a current authoring default was supplied. + **Resolve the questions.** Answer, defer with a reason, or overrule the severity — the model proposes it so you are not triaging a flat list, but you decide what matters: @@ -729,21 +756,20 @@ The edge keeps its real state; the override is recorded next to it, and it applies to **that graph revision only** — a later correction re-blocks the item rather than inheriting a judgement nobody made about it. -### What it could not read is part of the answer +### What the current item parser could not read is part of the answer ```bash $ agent-harness plan examples/PLAN.md --repo owner/name --dry-run -dependencies: - W4: external target(s) external:github-issue:owner/name#42 — needs a resolver -4 work items, 3 headings skipped as narrative +4 work items, additional plan-contract headings reported as narrative would create missing labels: area:api, area:docs would sync: created 4, updated 0, unchanged 0 ``` -Those 3 skipped headings are `Widget service`, `Background` and `Dependencies` -— narrative and the graph block, as expected. **A large skip count relative to -items means your plan does not use a recognised shape**, and the harness would -rather tell you than quietly find three items in a fifty-item plan. +The current parser recognises work-item headings and reports every other +heading rather than silently dropping it. The target validator must go further: +the project-level headings in the example become required, validated content +rather than merely reported narrative. **A large skip count relative to items +still means the current parser may not understand the plan's work shape.** The `dependencies:` block above it is the other half of the same idea: every line there is something that *will* hold work back, said before the issues @@ -751,7 +777,10 @@ exist rather than after the queue has stopped. --- -## 2. Sync it to GitHub +## 2. Optional legacy/extension path: sync it to GitHub + +This is implemented behaviour, not a step in the minimum local lifecycle. Skip +it for a local-only run. ```bash agent-harness plan examples/PLAN.md --repo owner/name diff --git a/examples/PLAN.md b/examples/PLAN.md index 696ecaa..ae2bd42 100644 --- a/examples/PLAN.md +++ b/examples/PLAN.md @@ -1,16 +1,132 @@ # Widget service -A worked example. Narrative like this paragraph is ignored — only items become -work, and everything skipped is reported so you can see what was left out. +This is a parser-compatible, local-only worked example. It includes the +human-facing sections and version-one executable manifest required by the +target contract in [`minimal.md`](../minimal.md). + +## Project identity and brief + +- Project key: `widgets` +- Local repository: supplied when the plan is admitted +- Brief: add unique widget serial numbers to an existing local service and show + them through its API. + +## Scope and non-goals + +### In scope + +- Persist a unique serial number for every widget. +- Reject duplicate serial numbers at the API boundary. +- Return serial numbers from the existing list endpoint. +- Document the local change. + +### Non-goals + +- No new authentication scheme. +- No remote repository, hosted CI/CD, or non-local deployment work. + +## Rough architecture + +The existing service has a persistence layer, an HTTP API, and automated tests. +The migration changes persistence first; API validation and response changes +depend on that schema. The changelog describes the accepted integrated result. + +## Data model + +`Widget` gains a non-null `serial` string with a uniqueness constraint. The +change requires a forward migration and a safe failure for existing duplicate +or missing data. No other entity changes ownership or lifecycle. + +## GUI and interfaces + +There is no GUI in this plan. The existing create and list HTTP endpoints are +the user-facing interfaces. Duplicate creation returns HTTP 409 with a useful +error, and list results include `serial`. + +## Execution and local delivery + +The runner uses the repository's locked Python toolchain and no supporting +service or secret. Checks run without network access. Delivery is a local +process owned by the harness; readiness and acceptance run on the host. + +```harness +version = 1 + +[project] +key = "widgets" +name = "Widget service" + +[repository] +base_ref = "main" +integration_ref = "harness/widgets" + +[agents] +role_runner = "agent-loop" +required_roles = ["implementer", "reviewer"] +max_workers = 2 +max_attempts = 5 +max_item_seconds = 3600 +max_item_spend_usd = 0.0 +max_hold_seconds = 21600 + +[execution] +backend = "docker" +network = "none" +toolchains = ["Python 3.12"] +system_packages = [] + +[execution.image] +strategy = "existing" +reference = "example/runner@sha256:0000000000000000000000000000000000000000000000000000000000000000" +pull = false + +[execution.limits] +command_timeout_seconds = 300 +memory = "2g" +cpus = "2" +pids = 512 +user = "1000:1000" +rootfs_read_only = true +tmpfs_size = "512m" + +[[execution.probes]] +name = "python" +command = ["python", "--version"] +expect_regex = "^Python 3\\.12" + +[checks] +item = [["python", "-m", "pytest", "-q"]] +integration = [["python", "-m", "pytest", "-q"]] + +[local_delivery] +backend = "local-process" +build = ["python", "-m", "build"] +build_context = "runner" +readiness = ["python", "scripts/readiness.py"] +readiness_context = "host" +acceptance = ["python", "-m", "pytest", "-q", "acceptance"] +acceptance_context = "host" +required_host_tools = ["python"] +command_timeout_seconds = 900 +readiness_timeout_seconds = 120 +readiness_interval_seconds = 2 +``` -## Background +## Cross-cutting requirements -More narrative. Not work. +- Security: error responses must not expose database or query details. +- Privacy: serial numbers are product identifiers, not personal data. +- Accessibility: not applicable; this plan adds no GUI. +- Performance: the list endpoint must not add a per-row query. +- Observability: duplicate rejections use the service's existing structured + error event without logging request credentials. +- Compatibility: preserve all existing API fields and behaviours. +- Licensing: use only dependencies permitted by the repository's existing + licence policy. -## Dependencies +## Work items -The graph can be stated in one place instead of repeated per item. The arrow -follows the work: `W1 -> W3` means W3 waits for W1. +The dependency block follows the work: `W1 -> W3` means W3 waits for W1. ```dependencies W1 -> W3 @@ -18,33 +134,78 @@ W1 -> W3 ### W1: Add a serial-number column -Add a `serial` column to the widgets table, unique and non-null, with a -migration. Two widgets must not be able to share a serial number. +**Deliverable:** A migration and persistence model that require a unique, +non-null widget serial number. + +**Acceptance:** + +- The migration applies to a clean and representative existing local database. +- A test proves two widgets cannot share a serial number. +- A failed migration leaves the original local database recoverable. + +Dependencies: none. labels: area:store -**Acceptance:** a migration exists and a test asserts the uniqueness constraint. ### W2: Reject duplicate serials at the API -Return 409 with a useful message when a widget is created with a serial that -already exists, rather than surfacing a database error. +**Deliverable:** The create endpoint returns a stable conflict response for a +serial number that already exists. + +**Acceptance:** + +- An API test expects HTTP 409 and the documented error code. +- The response contains no database implementation detail. depends on: W1 + labels: area:api ### W3: Show serials in the listing -Add the serial to the widget list response and its test. +**Deliverable:** The existing widget list response includes each serial number. + +**Acceptance:** + +- The response-schema and endpoint tests include `serial`. +- The query-count assertion proves the change adds no per-row query. -Its dependency on W1 is declared by the arrow block above rather than here. +depends on: W1 + +Its dependency is also declared by the graph block above; repeated declarations +must agree. labels: area:api -### W4: Announce the change +### W4: Document the accepted local change + +**Deliverable:** The local changelog describes the serial-number behaviour and +the compatibility impact. + +**Acceptance:** + +- The entry links the schema, conflict response, and list-response changes. +- Documentation checks pass on the integrated local branch. -Update the changelog once the tracking issue is closed. An external target has -to name its kind and its resolver: nothing here can see the other system, so -`unresolved` is a blocker rather than an assumption. +depends on: W2, W3 -depends on: W3, external:github-issue:owner/name#42 labels: area:docs + +## Local definition of done + +- W1–W4 are accepted and promoted into the local plan branch. +- Integrated checks pass from the exact accepted commit. +- The service builds, deploys locally, becomes ready, and passes the API + acceptance suite. +- Teardown succeeds and evidence names the plan revision, accepted commit, + execution profile, commands, and outcomes. +- No remote repository or deployment state is changed. + +## Assumptions, risks, and open questions + +- Assumption: the repository already has a migration and local service test + convention; preflight must reject the plan if it does not. +- Risk: existing data may lack valid serials. The migration item owns detection, + a documented remediation path, and recovery testing. +- Open questions: none. A discovered material ambiguity becomes a batched plan + question before admission or a durable item hold after admission. diff --git a/minimal.md b/minimal.md new file mode 100644 index 0000000..dfb98d2 --- /dev/null +++ b/minimal.md @@ -0,0 +1,457 @@ +# Minimal local product contract + +**Status:** target product contract + +**Effective:** 2026-08-09 + +**Applies to:** the generic, open-source `agent-harness` core + +This document defines the smallest useful product the repository is aiming to +deliver. It is the authority for product scope. [`docs/STATUS.md`](docs/STATUS.md) +records how much of this contract exists today; design and historical plan +documents may describe implemented or previously proposed capabilities without +changing this target. + +## 1. Product promise + +Given: + +1. a local Git repository; +2. a plan that satisfies the generic minimum plan contract below; and +3. a declared local execution and deployment topology, + +the harness can validate the plan, create a dependency-aware work queue, run +agents in isolated local workspaces, integrate their accepted changes into a +local plan branch, build and deploy that integrated result locally, run the +declared acceptance checks, tear it down, and retain evidence explaining every +decision. + +The minimum product does not need project-specific changes to harness core. A +new language, framework, repository layout, build tool, or local deployment +shape is supplied by the plan, an execution profile, or an installed adapter. + +The minimum product never pushes a branch, opens or merges a pull request, +changes a remote issue, invokes hosted CI/CD, or deploys to a shared, staging, +or production environment. A human may push the accepted local branch through +their existing delivery flow after the harness finishes; that is outside this +contract. + +Building a reviewed runner image, starting project-owned supporting services, +and building/deploying the product locally are deliberate local harness +operations, not CI/CD. Each requires the admitted configuration, explicit +operator action where it mutates local resources, and durable evidence. + +## 2. Why the boundary is local + +Keeping the code lifecycle local removes credentials, vendor APIs, repository +governance, hosted runner semantics, deployment permissions, and production +rollback policy from the generic core. Git is still fundamental: + +- every item starts from an exact local base commit; +- agents work in separate local worktrees or equivalent isolated workspaces; +- accepted item commits are promoted into one local integration branch; +- conflicts and stale-base changes are replayed and re-gated locally; +- the accepted result and its evidence remain inspectable in the local repo. + +For this contract, **local merge** means promotion of accepted item commits into +the plan's local integration branch. Updating another local target ref may be a +separate, explicitly reviewed finalisation action. The harness does not need to +rewrite the developer's checked-out branch to prove completion, and neither +operation implies a remote push. + +**Local deployment** means a deployment reachable and owned from the selected +harness host: for example a child process, Docker/Podman Compose project, local +Kubernetes cluster, or local VM. A plan may use an installed adapter for another +local topology. The adapter is configuration, not a reason to edit core. + +## 3. The minimum lifecycle + +```text +PLAN.md + local Git repo + | + v +deterministic validation --invalid--> one complete rejection report + | + valid + v +optional semantic review --material ambiguity--> one batched question report + | + admitted + v +register project + persist plan revision + create dependency-aware queue + | + v +isolated item work -> declared checks -> review -> local item commit + | + v +serial promotion/replay/re-gating on local integration branch + | + v +integrated build -> local deploy -> readiness -> acceptance -> teardown + | + v +accepted local branch + durable evidence +``` + +Plan admission is fail-closed. Queue rows are not created for a rejected or +unresolved plan. + +## 4. Minimum plan contract + +The user authors the plan from a template. The admission path does not conduct +a question-by-question interview to manufacture missing content. + +A plan must contain the following information: + +1. **Project identity and brief** — a stable project key, a concise description, + and the local repository path or an explicit path supplied at invocation. +2. **Scope and non-goals** — what this plan will and will not deliver. +3. **Rough architecture** — components, responsibilities, boundaries, and the + important flows between them. +4. **Data model** — important entities, ownership, persistence, and migration + expectations, or an explicit `Not applicable` with a reason. +5. **GUI and interfaces** — the intended user-facing UI and/or API/CLI surfaces, + or an explicit `Not applicable` with a reason. +6. **Local agent execution profile** — toolchains, system packages, supporting + services, mounts, dependency provisioning, model access, and network policy. +7. **Authoritative checks** — deterministic commands that establish item and + integrated correctness. +8. **Local delivery topology** — integrated build, local deploy, readiness, + acceptance, teardown, and recovery/rollback behaviour. +9. **Cross-cutting requirements** — security, privacy, accessibility, + performance, observability, compatibility, and licensing requirements that + apply, with explicit `Not applicable` entries where appropriate. +10. **Work items** — stable IDs, deliverables, acceptance criteria, and typed or + untyped dependencies sufficient to form an acyclic graph. +11. **Local definition of done** — the evidence that must exist before the plan + is locally complete. +12. **Assumptions, risks, and open questions** — including who must resolve each + blocking question. + +The structured values supplied by a plan for the harness to execute must use a +versioned, machine-readable manifest embedded in the Markdown plan. Prose +explains intent; the manifest is the source of truth for executable commands +and environment requirements. Commands are argument arrays, not shell +fragments. Version 1 is exactly one fenced `harness` block containing TOML. +Unknown versions and fields are rejected. The parser, validator, canonical +representation, and future migration rules are implementation deliverables; +values must not be inferred from arbitrary prose. + +The plan names required model roles and a role-runner capability, but it does +not carry model-provider endpoints, model credentials, or other deployment +secrets. The operator supplies those mappings out-of-band; admission shows and +binds the safe effective routing facts before any work can start. + +### Generic plan template + +This is the minimum authoring shape. Placeholder values and unexplained empty +sections are validation errors. The `harness` block below is the selected v1 +authoring boundary. It is not accepted by the current CLI until its validator +is implemented; [`BACKLOG.md`](BACKLOG.md) defines that work and the field +semantics. + +````markdown +# + +## Project identity and brief + +- Project key: `` +- Local repository: `` +- Brief: + +## Scope and non-goals + +### In scope + +- + +### Non-goals + +- + +## Rough architecture + + + +## Data model + +"> + +## GUI and interfaces + +"> + +## Execution and local delivery + +```harness +version = 1 + +[project] +key = "" +name = "" + +[repository] +base_ref = "main" +integration_ref = "harness/" + +[agents] +role_runner = "" +required_roles = ["implementer", "reviewer"] +max_workers = 2 +max_attempts = 5 +max_item_seconds = 3600 +max_item_spend_usd = 0.0 +max_hold_seconds = 21600 + +[execution] +backend = "" +network = "" +toolchains = [""] +system_packages = [] + +[execution.image] +strategy = "" +# Existing: set a digest-qualified `reference` and reviewed `pull` policy. +# Build: select a repository Containerfile or a generated recipe whose base, +# copied paths, and setup argv arrays are all present for review. + +[execution.limits] +command_timeout_seconds = 300 +memory = "2g" +cpus = "2" +pids = 512 +user = "1000:1000" +rootfs_read_only = true +tmpfs_size = "512m" + +[[execution.probes]] +name = "" +command = ["", ""] + +[[execution.mounts]] +source = "" +target = "" +writable = false + +[[execution.secrets]] +name = "" +source = "environment" +scope = "" + +[execution.config] +# Backend-specific supporting-service and runtime configuration. The selected +# installed adapter must validate this without changing the host. + +[checks] +item = [["", ""]] +integration = [["", ""]] + +[local_delivery] +backend = "" +build = [["", ""]] +build_context = "runner" +readiness = [["", ""]] +readiness_context = "host" +acceptance = [["", ""]] +acceptance_context = "host" +required_host_tools = [""] +command_timeout_seconds = 900 +readiness_timeout_seconds = 120 +readiness_interval_seconds = 2 +teardown_timeout_seconds = 30 + +[local_delivery.config] +# Adapter-specific start, owned-resource, command-context, teardown, and +# recovery configuration. Core owns stage order and outcomes; the selected +# adapter owns the local technology and validates these fields. +``` + +## Cross-cutting requirements + +- Security: "> +- Privacy: "> +- Accessibility: "> +- Performance: "> +- Observability: "> +- Compatibility: "> +- Licensing: "> + +## Work items + +### W1: + +deliverable: code + +**Deliverable:** + +**Acceptance:** + +- + +depends on: none + +### W2: + +deliverable: code + +**Deliverable:** + +**Acceptance:** + +- + +depends on: W1 + +## Local definition of done + +- Every item is accepted and promoted into the local integration branch. +- The integrated build, local deployment, readiness, and acceptance commands pass. +- Teardown succeeds and retained evidence identifies the exact accepted commit. +- + +## Assumptions, risks, and open questions + +- Assumption: +- Risk: +- Open question: +```` + +## 5. Admission behaviour + +Admission has two deliberately separate stages. + +### 5.1 Deterministic minimum validation + +The validator parses without model access and returns all findings in one run. +It rejects a plan when required content is absent, a placeholder remains, an +`N/A` lacks a reason, executable configuration is malformed, a command cannot +be represented safely, an item is incomplete, a dependency is unresolved, or +the graph is cyclic. + +Every finding has a stable code, severity, source location, and remediation. +For example: + +```text +PLAN-M104 error execution.network is missing (`none` or `project`) +PLAN-W203 error W3: dependency "W9" does not exist +PLAN-D302 error Local definition of done: acceptance evidence is not specified +``` + +The CLI exits non-zero and creates no project, plan revision, or queue rows. +This report replaces an onboarding interview; rerunning validation is cheap and +deterministic. + +### 5.2 Post-minimum semantic review + +Only after deterministic validation passes may a reviewer identify material +ambiguities that syntax cannot settle. Questions are returned together, tied to +a section or work item, and explain why the answer affects execution or +acceptance. They do not silently invent architecture or project policy. + +A blocking question prevents admission until the plan is revised or an answer +is durably incorporated. Non-blocking questions become explicit assumptions or +item holds. Agent questions during execution use the existing hold mechanism; +they do not weaken admission requirements. + +## 6. Dependencies and runner images + +Local-only does not mean dependency-free. A project may need language runtimes, +compilers, package downloads, browsers, databases, containers, or a model +endpoint. Genericity comes from declaring those needs rather than baking one +workload's assumptions into core. + +A required dependency in the minimum path cannot depend on a hosted issue or +other external resolver. It may name work in this plan, an explicit human +decision, or an exact admitted revision of another local harness project. +External references may remain advisory evidence or belong to an optional +remote extension, but they cannot make the local minimum wait on remote state. + +The minimum product supports a per-project execution profile by either: + +- accepting an existing immutable runner image reference; or +- generating a reviewable runner recipe from declared requirements, building + it locally, testing it, and pinning the resulting digest before work begins. + +The harness must never silently execute arbitrary installation prose. Image +generation, mounts, services, network access, and named secret injection are +shown during preflight and are auditable. Secrets are referenced by name and +supplied out-of-band; they do not belong in `PLAN.md` or the event store. + +The **agent runner image** and the **product deployment image/topology** are +separate concepts. A plan may use the former to edit and test code while using +a process, Compose project, local cluster, VM, or installed adapter for the +latter. + +## 7. Genericity and extension rules + +- Core consumes protocols and configuration, never a named project's layout or + a vendor-specific log/deployment format. +- Vendor or topology knowledge lives in an installed adapter discovered through + metadata, following the repository's existing adapter rule. +- A supplied baseline is data, not a universal constant. +- Project commands cannot weaken the harness's evidence, policy, or review + gates. The gates remain the product. +- The local command runner is a screened execution boundary, not a claim of + perfect sandboxing. +- Unsupported requirements fail preflight before any item is claimed. + +## 8. Current product compared with this contract + +As of 2026-08-09, the repository is **partially aligned, not yet the minimal +product described above**. + +| Capability | Current product state | Gap to this contract | +|---|---|---| +| Generic plan parsing | Parses Markdown work items, dependencies, duplicates, unresolved references, malformed dependency clauses, cycles, and unattached prose. | No normative project-level plan schema or validation of architecture, data, interfaces, execution profile, local delivery, or definition of done. | +| Admission diagnostics | Some parse/dependency findings block plan sync. | No complete stable-code rejection report and no fail-closed admission transaction covering project, plan revision, and queue creation. | +| Plan authoring | `inception`/survey flows can ask questions and produce Markdown. | Interactive authoring is currently too central. It must become optional tooling, not the minimum admission path. | +| Work execution | Queue leases, attempts, budgets, holds, checks, review, audit, role runners, and local worktrees exist. | The full path has not been accepted against a real project fleet; unsupported project requirements are not yet captured by the minimum plan contract. | +| Local Git integration | Item commits, per-plan integration branches, serialized promotion, replay, re-gating, and dependent-item waiting are implemented and fixture-tested. | End-to-end live acceptance remains incomplete; local finalisation semantics need to be exposed as a reviewed product action. | +| Execution environment | Host and Docker execution paths exist; the service can be configured with image and mount values. The Docker agent loop is isolated, but authoritative item and promotion checks still execute on the controller host. | Image, mounts, services, network policy, and toolchains are primarily deployment-wide rather than admitted and pinned per project. No reviewable image-generation lifecycle exists, and the admitted profile does not yet answer every gate. | +| Integrated checks | Project checks and promotion-time re-gating exist. | No distinct, plan-declared final build/readiness/acceptance contract. | +| Local product deployment | Monitoring/API/GUI deployment is documented for the harness itself. | There is no generic lifecycle that deploys the product-under-development locally, verifies readiness and acceptance, tears it down, and records the result. | +| Remote workflow | Remote publication, issue, review, and hosted-flow integrations exist in parts of the code and tests. | They are outside the minimum target and must not remain prerequisites for the local path. They may survive as optional extensions. | +| Evidence from real work | A real-daemon Stage 2 run produced partial evidence; fixture evidence covers later Git integration mechanics. | No complete real-project run has satisfied this contract. The repository must not claim otherwise. | + +Detailed current status and the implementation sequence are in +[`docs/STATUS.md`](docs/STATUS.md). + +## 9. Minimum release acceptance + +The target is reached only when all of the following are demonstrated: + +1. A fresh user can copy the published plan template, fill it without an + interview, and get one deterministic rejection report for an invalid plan. +2. A valid plan is admitted atomically into a local project and dependency-aware + queue without remote credentials. +3. Preflight proves that the pinned per-project execution environment can run + every required tool and access every declared local service. +4. At least one representative project completes through isolated item work, + checks, review, local Git integration, integrated build, local deployment, + readiness, acceptance, and teardown. +5. The evidence names the plan revision, base and accepted commits, runner image + digest/profile, commands, outcomes, and any human decisions. +6. A second project using a materially different toolchain and local topology + can use the same core without modifying an execution-path module. +7. The genericity tests and all repository gates pass. +8. No success criterion depends on GitHub/GitLab, hosted CI, a remote branch, a + remote issue tracker, or a non-local deployment target. + +Until these criteria pass, status remains pre-alpha and capability claims must +name the narrower evidence actually obtained. + +## 10. Explicitly deferred + +The following may be useful later but are not prerequisites for the minimum +open-source product: + +- remote branch pushes and pull/merge request automation; +- hosted issue tracker state mutation; +- hosted CI/CD triggering or observation; +- shared development, staging, or production deployments; +- automatic production rollback; +- organisation-specific approval, identity, or repository policy; +- a registry for arbitrary third-party gates (decision D8 remains open). + +Deferring these features does not delete existing code or invalidate historical +evidence. It prevents them from defining or blocking the generic local path. diff --git a/nextsteps.md b/nextsteps.md new file mode 100644 index 0000000..a356b69 --- /dev/null +++ b/nextsteps.md @@ -0,0 +1,39 @@ +# Next steps + +**Current target:** [`minimal.md`](minimal.md) + +**Current state and milestone exits:** [`docs/STATUS.md`](docs/STATUS.md) + +**Exploration-complete implementation backlog:** [`BACKLOG.md`](BACKLOG.md) + +This file previously contained a Rainmon/Node-B acceptance runbook. That +runbook mixed one consumer's repository, credentials, CI policy, deployment +topology, and prerequisites with the generic harness product. It is superseded. + +Rainmon may be used later as one real acceptance project, but its requirements +must be supplied by a valid project plan and execution profile. They are not +changes that the harness core should require every user to make. + +The current implementation order is: + +1. implement the versioned generic minimum plan and one-pass validator; +2. make valid-plan admission reviewed, atomic, and idempotent; +3. persist and preflight a per-project execution profile, including an existing + immutable runner image or a reviewable locally generated image; +4. complete the admitted plan through the existing local worktree, gate, + review, commit, and integration-branch machinery against a real daemon; +5. add the integrated build, local deploy, readiness, acceptance, teardown, and + recovery lifecycle for the product under development; +6. prove the same core with two materially different projects and local + topologies. + +Do not add hosted CI/CD, remote branch/issue/review mutation, or a non-local +deployment prerequisite to unblock these milestones. Do not add a project name, +repository layout, language, toolchain, or topology to core. If a local topology +needs specific knowledge, supply it through configuration or an installed +adapter. + +This file intentionally does not duplicate detailed tasks or status. The +normative boundary lives in `minimal.md`, the implementation-ready work and +dependency spine live in `BACKLOG.md`, and the current milestone/evidence +record lives in `docs/STATUS.md`. diff --git a/src/agent_harness/__main__.py b/src/agent_harness/__main__.py index 97b1b03..36c287f 100644 --- a/src/agent_harness/__main__.py +++ b/src/agent_harness/__main__.py @@ -144,6 +144,92 @@ def _plan(args: argparse.Namespace) -> int: return 0 +def _plan_validate(args: argparse.Namespace) -> int: + """Validate a local plan and print the complete deterministic report.""" + import json as _json + + from .plan_validation import validate_plan_file + + report = validate_plan_file(args.work if args.work is not None else args.path) + payload = report.as_dict() + if args.json: + print(_json.dumps(payload, indent=2, sort_keys=True)) + else: + if report.valid: + print("valid plan") + else: + for finding in report.findings: + location = ( + f"{finding.path}:{finding.line}:{finding.column}" + if finding.line is not None and finding.column is not None + else finding.path + ) + print(f"{finding.code} [{finding.severity}] {location}: {finding.message}") + print(f" fix: {finding.remediation}") + return 0 if report.valid else 2 + + +def _plan_admit(args: argparse.Namespace) -> int: + """Preview or apply a digest-bound local plan admission.""" + import json as _json + + from .admission_service import AdmissionError, apply, preview, revision_preview + from .plan_contract import parse_plan_contract + from .work import WorkQueue + + markdown = args.path.read_text(encoding="utf-8") + project_id = parse_plan_contract(markdown).project.key + queue = WorkQueue(args.db) + try: + proposal = preview(queue, project_id=project_id, plan_path=args.path, worktree=args.work) + changes = revision_preview( + queue, + admission=proposal, + plan_path=args.path, + worktree=args.work, + removed_items=dict(item.split("=", 1) for item in args.remove), + reopen_items=set(args.reopen), + high_risk_removals=set(args.high_risk_remove), + ) + payload = changes.as_dict() + if args.approve_digest is None: + print( + _json.dumps(payload, indent=2, sort_keys=True) + if args.json + else proposal.proposal_digest + ) + return 0 + if args.approve_digest != proposal.proposal_digest: + print("approval digest does not match this preview", file=sys.stderr) + return 2 + revision = apply( + queue, + proposal=proposal, + plan_path=args.path, + worktree=args.work, + operator=args.operator, + expected_base_sha=proposal.base_sha, + expected_current_revision=proposal.current_revision, + removed_items=dict(item.split("=", 1) for item in args.remove), + reopen_items=set(args.reopen), + high_risk_removals=set(args.high_risk_remove), + ) + result = { + "project_id": project_id, + "revision": revision, + "proposal_digest": proposal.proposal_digest, + } + print( + _json.dumps(result, sort_keys=True) + if args.json + else f"admitted {project_id} revision {revision}" + ) + return 0 + except (AdmissionError, ValueError, OSError) as exc: + print(f"admission: {exc}", file=sys.stderr) + return 2 + + def _init(args: argparse.Namespace) -> int: """Build the deterministic demo, and say what it is and is not. @@ -1247,8 +1333,25 @@ def main(argv: list[str] | None = None) -> int: help="re-ingest every SECONDS instead of exiting", ) - p_plan = sub.add_parser("plan", help="sync a plan .md into a GitHub backlog") - p_plan.add_argument("path", type=Path, help="the plan markdown file") + p_plan = sub.add_parser("plan", help="validate or sync a plan markdown file") + p_plan.add_argument("path", type=Path, nargs="?", help="the plan markdown file") + p_plan.add_argument( + "action", + nargs="?", + help="validate locally, or publish issues using the compatibility workflow", + ) + p_plan.add_argument("--json", action="store_true", help="emit validation as JSON") + p_plan.add_argument("--work", type=Path, help="plan path for `plan validate`") + p_plan.add_argument( + "--semantic-review", action="store_true", help="include semantic review in the proposal" + ) + p_plan.add_argument( + "--approve-digest", help="apply only when this exact preview digest matches" + ) + p_plan.add_argument("--operator", default="cli", help="operator identity recorded at admission") + p_plan.add_argument("--remove", action="append", default=[], metavar="ID=REASON") + p_plan.add_argument("--reopen", action="append", default=[], metavar="ID") + p_plan.add_argument("--high-risk-remove", action="append", default=[], metavar="ID") # Not required with --dry-run: a dry run contacts GitHub for nothing and # writes nothing, so demanding an owner/name meant inventing a repository # that does not exist in order to ask a purely local question — what can @@ -1921,6 +2024,31 @@ def main(argv: list[str] | None = None) -> int: ) if args.command == "plan": + # Keep the old `plan PLAN.md` spelling while accepting the clearer + # nested command spelling without introducing a second parser. + if str(args.path) in {"validate", "publish-issues", "admit"} and args.action: + action, plan_path = str(args.path), Path(args.action) + args.path, args.action = plan_path, action + if args.action == "validate": + if args.work is None: + if args.path is None: + print("plan validate requires PLAN.md", file=sys.stderr) + return 2 + args.work = args.path + return _plan_validate(args) + if args.action == "admit": + if args.path is None or args.work is None: + print("plan admit requires PLAN.md and --work REPO", file=sys.stderr) + return 2 + return _plan_admit(args) + if args.action == "publish-issues": + if args.path is None: + print("plan publish-issues requires PLAN.md", file=sys.stderr) + return 2 + return _plan(args) + if args.path is None: + print("plan requires PLAN.md or `validate PLAN.md`", file=sys.stderr) + return 2 return _plan(args) if args.command == "run": diff --git a/src/agent_harness/admission_service.py b/src/agent_harness/admission_service.py new file mode 100644 index 0000000..723ae8c --- /dev/null +++ b/src/agent_harness/admission_service.py @@ -0,0 +1,510 @@ +"""Read-only plan admission preview. + +The preview is a proposal, not a decision. It performs deterministic plan +validation and read-only local Git inspection, but creates no project, queue, +revision, ref, worktree, model call, or external request. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from .graph import parse_dependencies +from .plan_contract import PlanContract, PlanContractError, parse_plan_contract +from .plan_revisions import acceptance_for_item, current_revision, revision_items +from .plan_validation import ValidationReport, validate_plan +from .work import BLOCKED, CLAIMED, DONE, FAILED, HELD, STOPPED, WorkQueue + + +class AdmissionError(ValueError): + """A preview cannot be constructed from the supplied local facts.""" + + +class GitFacts(Protocol): + def __call__(self, argv: tuple[str, ...], cwd: Path, /) -> str: ... + + +def _git(argv: tuple[str, ...], cwd: Path) -> str: + result = subprocess.run( + ["git", *argv], + cwd=cwd, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise AdmissionError(result.stderr.strip() or f"git {' '.join(argv)} failed") + return result.stdout.strip() + + +@dataclass(frozen=True) +class AdmissionProposal: + proposal_digest: str + plan_digest: str + manifest_digest: str + project_id: str + worktree: str + repository_identity: str + base_ref: str + base_sha: str + integration_ref: str + finalise_ref: str | None + current_revision: int | None + validation: ValidationReport + + def as_dict(self) -> dict[str, object]: + return { + "proposal_digest": self.proposal_digest, + "plan_digest": self.plan_digest, + "manifest_digest": self.manifest_digest, + "project_id": self.project_id, + "worktree": self.worktree, + "repository_identity": self.repository_identity, + "base_ref": self.base_ref, + "base_sha": self.base_sha, + "integration_ref": self.integration_ref, + "finalise_ref": self.finalise_ref, + "current_revision": self.current_revision, + "validation": self.validation.as_dict(), + } + + +@dataclass(frozen=True) +class RevisionChange: + item_id: str + kind: str + old_state: str | None + new_state: str + requires_reopen: bool = False + removal_reason: str | None = None + + +@dataclass(frozen=True) +class RevisionProposal: + admission: AdmissionProposal + changes: tuple[RevisionChange, ...] + + def as_dict(self) -> dict[str, object]: + return { + **self.admission.as_dict(), + "changes": [ + { + "item_id": change.item_id, + "kind": change.kind, + "old_state": change.old_state, + "new_state": change.new_state, + "requires_reopen": change.requires_reopen, + "removal_reason": change.removal_reason, + } + for change in self.changes + ], + } + + +def revision_preview( + queue: WorkQueue, + *, + admission: AdmissionProposal, + plan_path: str | Path, + worktree: str | Path, + removed_items: dict[str, str] | None = None, + reopen_items: set[str] | None = None, + high_risk_removals: set[str] | None = None, + git: GitFacts = _git, +) -> RevisionProposal: + """Classify a revision without changing queue state.""" + removed = removed_items or {} + reopen = reopen_items or set() + high_risk = high_risk_removals or set() + current = preview( + queue, + project_id=admission.project_id, + plan_path=plan_path, + worktree=worktree, + git=git, + ) + if current.plan_digest != admission.plan_digest: + raise AdmissionError("proposal facts changed; preview the plan again") + project = queue.get_project(admission.project_id) + if project is not None and project.current_plan_revision is not None: + control_state, _ = queue.control(admission.project_id) + if control_state != STOPPED: + raise AdmissionError("plan revision requires a stopped project") + if queue.claimed(admission.project_id): + raise AdmissionError("plan revision requires no claimed or held items") + existing_revision = current_revision(queue, admission.project_id) + if ( + existing_revision is not None + and existing_revision.integration_ref != current.integration_ref + ): + raise AdmissionError("changing integration_ref requires a new project identity") + if existing_revision is not None and existing_revision.initial_base_sha != current.base_sha: + # This is informational in the diff model; the immutable new SHA is + # persisted by apply and never follows a moving branch implicitly. + pass + + contract = parse_plan_contract(Path(plan_path).read_text(encoding="utf-8")) + old_rows = { + row["item_id"]: row + for row in revision_items(queue, admission.project_id, admission.current_revision or 0) + if row["active"] + } + work_rows = {row.item_id: row for row in queue.items(project_id=admission.project_id)} + new_items = {item.id: item for item in contract.parsed.items} + omitted = sorted(set(old_rows) - set(new_items)) + missing_removals = [item_id for item_id in omitted if item_id not in removed] + if missing_removals: + raise AdmissionError( + "omitted items require explicit removed_items reasons: " + ", ".join(missing_removals) + ) + changes: list[RevisionChange] = [] + for item_id in sorted(set(old_rows) | set(new_items)): + old = old_rows.get(item_id) + item = new_items.get(item_id) + work = work_rows.get(item_id) + state: str | None = work.state if work is not None else None + if item is None: + if state in {CLAIMED, HELD}: + raise AdmissionError(f"cannot remove active item {item_id}") + if state in {DONE, FAILED, BLOCKED} and item_id not in high_risk: + raise AdmissionError(f"removing {state} item {item_id} requires high-risk approval") + changes.append( + RevisionChange( + item_id, "removed", state, state or "pending", removal_reason=removed[item_id] + ) + ) + continue + acceptance = acceptance_for_item(item.body) + changed = old is None or ( + old["title"] != item.title + or old["brief"] != item.brief() + or old["deliverable"] != item.deliverable + or json.loads(old["depends_on_json"]) != item.depends_on + or json.loads(old["acceptance_json"]) != acceptance + ) + if old is None: + changes.append(RevisionChange(item_id, "added", None, "pending")) + elif not changed: + changes.append(RevisionChange(item_id, "unchanged", state, state or "pending")) + else: + if state == DONE and item_id not in reopen: + raise AdmissionError( + f"done item {item_id} changed; explicit reopen approval is required" + ) + new_state = "pending" if state == DONE and item_id in reopen else state or "pending" + changes.append(RevisionChange(item_id, "changed", state, new_state, state == DONE)) + return RevisionProposal(admission=current, changes=tuple(changes)) + + +def apply( + queue: WorkQueue, + *, + proposal: AdmissionProposal, + plan_path: str | Path, + worktree: str | Path, + operator: str, + expected_base_sha: str, + expected_current_revision: int | None, + git: GitFacts = _git, + fail_at: str | None = None, + removed_items: dict[str, str] | None = None, + reopen_items: set[str] | None = None, + high_risk_removals: set[str] | None = None, +) -> int: + """Apply one reviewed proposal atomically and idempotently. + + Git is only read before the transaction. The transaction itself contains + no ref creation, process start, model call, or remote operation. + """ + current = preview( + queue, + project_id=proposal.project_id, + plan_path=plan_path, + worktree=worktree, + git=git, + ) + if current.plan_digest != proposal.plan_digest: + raise AdmissionError("proposal facts changed; preview the plan again") + revision = revision_preview( + queue, + admission=proposal, + plan_path=plan_path, + worktree=worktree, + removed_items=removed_items, + reopen_items=reopen_items, + high_risk_removals=high_risk_removals, + git=git, + ) + if current.base_sha != expected_base_sha: + raise AdmissionError("the reviewed base commit changed") + if ( + current.current_revision != expected_current_revision + and current.current_revision is not None + ): + # An already-present exact plan is the idempotent replay case. The + # transaction below proves that identity before returning it. + existing_check = queue._connect() + try: + existing_row = existing_check.execute( + "SELECT 1 FROM plan_revisions WHERE project_id = ? AND plan_digest = ?", + (proposal.project_id, proposal.plan_digest), + ).fetchone() + finally: + existing_check.close() + if existing_row is None: + raise AdmissionError("the reviewed current revision changed") + markdown = Path(plan_path).read_text(encoding="utf-8") + contract = parse_plan_contract(markdown) + now = float(queue.now()) + conn = queue._connect() + try: + conn.execute("BEGIN IMMEDIATE") + existing = conn.execute( + "SELECT revision FROM plan_revisions WHERE project_id = ? AND plan_digest = ?", + (proposal.project_id, proposal.plan_digest), + ).fetchone() + if existing is not None: + conn.commit() + return int(existing["revision"]) + live = conn.execute( + "SELECT current_plan_revision FROM projects WHERE project_id = ?", + (proposal.project_id,), + ).fetchone() + live_revision = live["current_plan_revision"] if live is not None else None + if live_revision != expected_current_revision: + raise AdmissionError("the current revision changed during admission") + revision_row = conn.execute( + "SELECT COALESCE(MAX(revision), 0) AS revision FROM plan_revisions " + "WHERE project_id = ?", + (proposal.project_id,), + ).fetchone() + revision_number = int(revision_row["revision"]) + 1 + conn.execute( + "INSERT INTO projects (project_id, name, repo, work_dir, base_branch, plan_branch, " + "current_plan_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) " + "ON CONFLICT(project_id) DO UPDATE SET name=excluded.name, repo=excluded.repo, " + "work_dir=excluded.work_dir, base_branch=excluded.base_branch, " + "plan_branch=excluded.plan_branch, updated_at=excluded.updated_at", + ( + proposal.project_id, + contract.project.name, + proposal.repository_identity, + str(Path(worktree).resolve()), + contract.repository.base_ref, + contract.repository.integration_ref, + now, + now, + ), + ) + if fail_at == "project": + raise RuntimeError("injected admission failure: project") + conn.execute( + "INSERT INTO control (project_id, state, changed_at) VALUES (?, ?, ?) " + "ON CONFLICT(project_id) DO UPDATE SET state=excluded.state, " + "changed_at=excluded.changed_at", + (proposal.project_id, STOPPED, now), + ) + conn.execute( + "INSERT INTO plan_revisions (project_id, revision, plan_digest, manifest_digest, " + "plan_markdown, manifest_json, repository_identity, initial_base_sha, integration_ref, " + "finalise_ref, adapter_versions_json, admitted_by, admitted_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)", + ( + proposal.project_id, + revision_number, + proposal.plan_digest, + proposal.manifest_digest, + markdown, + contract.canonical_json(), + proposal.repository_identity, + proposal.base_sha, + proposal.integration_ref, + proposal.finalise_ref, + operator, + now, + ), + ) + if fail_at == "revision": + raise RuntimeError("injected admission failure: revision") + change_by_id = {change.item_id: change for change in revision.changes} + old_rows = { + row["item_id"]: row + for row in conn.execute( + "SELECT * FROM plan_revision_items WHERE project_id = ? AND revision = ?", + (proposal.project_id, expected_current_revision or 0), + ) + } + for ordinal, item in enumerate(contract.parsed.items): + change = change_by_id[item.id] + state = change.new_state + conn.execute( + "INSERT INTO plan_revision_items (project_id, revision, item_id, ordinal, title, " + "brief, deliverable, depends_on_json, acceptance_json, active) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", + ( + proposal.project_id, + revision_number, + item.id, + ordinal, + item.title, + item.brief(), + item.deliverable, + json.dumps(item.depends_on), + json.dumps(acceptance_for_item(item.body)), + ), + ) + existing_work = conn.execute( + "SELECT 1 FROM work WHERE project_id = ? AND item_id = ?", + (proposal.project_id, item.id), + ).fetchone() + if existing_work is None: + conn.execute( + "INSERT INTO work (project_id, item_id, title, brief, depends_on, state, " + "branch, " + "updated_at, deliverable, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", + ( + proposal.project_id, + item.id, + item.title, + item.brief(), + json.dumps(item.depends_on), + state, + f"refs/heads/harness/{proposal.project_id}/r{revision_number}/{item.id}", + now, + item.deliverable, + ), + ) + else: + conn.execute( + "UPDATE work SET title = ?, brief = ?, depends_on = ?, deliverable = ?, " + "state = ?, active = 1, updated_at = ? WHERE project_id = ? AND item_id = ?", + ( + item.title, + item.brief(), + json.dumps(item.depends_on), + item.deliverable, + state, + now, + proposal.project_id, + item.id, + ), + ) + queue.graph.set_edges( + proposal.project_id, + item.id, + parse_dependencies(item.depends_on), + conn=conn, + ) + for item_id, change in change_by_id.items(): + if change.kind != "removed": + continue + old = old_rows.get(item_id) + if old is None: + continue + conn.execute( + "INSERT INTO plan_revision_items (project_id, revision, item_id, ordinal, title, " + "brief, deliverable, depends_on_json, acceptance_json, active) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + ( + proposal.project_id, + revision_number, + item_id, + len(contract.parsed.items), + old["title"], + old["brief"], + old["deliverable"], + old["depends_on_json"], + old["acceptance_json"], + ), + ) + conn.execute( + "UPDATE work SET active = 0, updated_at = ? WHERE project_id = ? AND item_id = ?", + (now, proposal.project_id, item_id), + ) + queue.graph.set_edges(proposal.project_id, item_id, [], conn=conn) + if fail_at == "items": + raise RuntimeError("injected admission failure: items") + conn.execute( + "UPDATE projects SET current_plan_revision = ?, updated_at = ? WHERE project_id = ?", + (revision_number, now, proposal.project_id), + ) + if fail_at == "before_commit": + raise RuntimeError("injected admission failure: before_commit") + conn.commit() + return revision_number + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _manifest_hash(contract: PlanContract) -> str: + return hashlib.sha256(contract.canonical_json().encode("utf-8")).hexdigest() + + +def preview( + queue: WorkQueue, + *, + project_id: str, + plan_path: str | Path, + worktree: str | Path, + git: GitFacts = _git, +) -> AdmissionProposal: + """Build an admission proposal without changing local or queue state.""" + plan = Path(plan_path) + root = Path(worktree).resolve() + if not plan.is_file(): + raise AdmissionError(f"plan file does not exist: {plan}") + markdown = plan.read_text(encoding="utf-8") + plan_digest = hashlib.sha256(plan.read_bytes()).hexdigest() + validation = validate_plan(markdown, source=str(plan)) + if not validation.valid: + raise AdmissionError(json.dumps(validation.as_dict(), sort_keys=True)) + try: + contract = parse_plan_contract(markdown) + except PlanContractError as exc: + raise AdmissionError(str(exc)) from exc + try: + repository_identity = git(("rev-parse", "--show-toplevel"), root) + base_sha = git( + ("rev-parse", "--verify", f"{contract.repository.base_ref}^{{commit}}"), root + ) + except (AdmissionError, OSError) as exc: + raise AdmissionError(f"could not resolve local repository facts: {exc}") from exc + current = current_revision(queue, project_id) + manifest_digest = _manifest_hash(contract) + proposal_body = { + "base_ref": contract.repository.base_ref, + "base_sha": base_sha, + "current_revision": current.revision if current else None, + "finalise_ref": contract.repository.finalise_ref, + "integration_ref": contract.repository.integration_ref, + "manifest_digest": manifest_digest, + "plan_digest": plan_digest, + "project_id": project_id, + "repository_identity": repository_identity, + "worktree": str(root), + } + proposal_digest = hashlib.sha256( + json.dumps(proposal_body, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + return AdmissionProposal( + proposal_digest=proposal_digest, + plan_digest=plan_digest, + manifest_digest=manifest_digest, + project_id=project_id, + worktree=str(root), + repository_identity=repository_identity, + base_ref=contract.repository.base_ref, + base_sha=base_sha, + integration_ref=contract.repository.integration_ref, + finalise_ref=contract.repository.finalise_ref, + current_revision=current.revision if current else None, + validation=validation, + ) diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index eabd8fa..910727d 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -30,6 +30,9 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from . import __version__ +from .admission_service import AdmissionError +from .admission_service import apply as admission_apply +from .admission_service import preview as admission_preview from .adoption_service import ( AdoptionConfigurationError, AdoptionInspectionFailure, @@ -46,9 +49,11 @@ from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED from .events import Event as AuditEvent from .maintenance import DEFAULT_RETENTION_DAYS +from .plan_revisions import revision_items from .plan_service import PlanSyncConflict, PlanSyncFailure from .plan_service import execute as execute_plan_sync from .plan_service import parse_result as plan_parse_result +from .plan_validation import validate_plan_file from .preflight import BaseChecks from .process_metrics import ProcessMetricsSampler, ProcessMetricsSource from .project_service import configure_project, project_spec @@ -58,6 +63,10 @@ from .schemas import ( AddItemsRequest, AddItemsResult, + AdmissionApplyRequest, + AdmissionApplyResult, + AdmissionPreviewRequest, + AdmissionPreviewResult, AdoptionDecisionRequest, AdoptionInspectRequest, AdoptionReconcileRequest, @@ -100,8 +109,11 @@ OpenQuestion, OverdueHold, PlanParseResult, + PlanRevisionDetail, + PlanRevisionSummary, PlanSyncRequest, PlanSyncResult, + PlanValidationResult, PreflightCheck, PreflightResult, ProcessMetrics, @@ -1898,6 +1910,159 @@ def set_roles(request: RoleMap, _: None = Depends(require_token)) -> RoleMapView # ---------------------------------------------------------------- plan + @app.post( + "/api/plans/admission/preview", + tags=["plan"], + summary="Preview local plan admission without mutation", + response_model=AdmissionPreviewResult, + responses={400: {"description": "The plan or local Git facts are invalid."}}, + ) + def plan_admission_preview( + request: AdmissionPreviewRequest, + _: None = Depends(require_token), + ) -> AdmissionPreviewResult: + try: + proposal = admission_preview( + need_queue(), + project_id=request.project_id, + plan_path=request.plan_path, + worktree=request.worktree, + ) + except AdmissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return AdmissionPreviewResult.model_validate(proposal.as_dict()) + + @app.post( + "/api/plans/admission/apply", + tags=["plan"], + summary="Apply an exact reviewed admission proposal", + response_model=AdmissionApplyResult, + responses={409: {"description": "The reviewed proposal is stale."}}, + ) + def plan_admission_apply( + request: AdmissionApplyRequest, + _: None = Depends(require_token), + ) -> AdmissionApplyResult: + try: + proposal = admission_preview( + need_queue(), + project_id=request.project_id, + plan_path=request.plan_path, + worktree=request.worktree, + ) + idempotent = False + if proposal.proposal_digest != request.proposal_digest: + existing = need_queue()._connect() + try: + same_plan = existing.execute( + "SELECT 1 FROM plan_revisions WHERE project_id = ? AND plan_digest = ?", + (request.project_id, proposal.plan_digest), + ).fetchone() + finally: + existing.close() + if same_plan is None: + raise AdmissionError("proposal digest does not match the current preview") + idempotent = same_plan is not None + else: + existing = need_queue()._connect() + try: + idempotent = ( + existing.execute( + "SELECT 1 FROM plan_revisions WHERE project_id = ? AND plan_digest = ?", + (request.project_id, proposal.plan_digest), + ).fetchone() + is not None + ) + finally: + existing.close() + revision = admission_apply( + need_queue(), + proposal=proposal, + plan_path=request.plan_path, + worktree=request.worktree, + operator=request.operator, + expected_base_sha=request.expected_base_sha, + expected_current_revision=request.expected_current_revision, + removed_items=request.removed_items, + reopen_items=set(request.reopen_items), + high_risk_removals=set(request.high_risk_removals), + ) + except AdmissionError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return AdmissionApplyResult( + project_id=request.project_id, + revision=revision, + idempotent=idempotent, + state="stopped", + ) + + @app.get( + "/api/projects/{project_id}/plan-revisions", + tags=["plan"], + summary="List immutable admitted plan revisions", + response_model=list[PlanRevisionSummary], + ) + def plan_revisions( + project_id: str = PathParam(description="Project id."), + _: None = Depends(require_token), + ) -> list[PlanRevisionSummary]: + connection = need_queue()._connect() + try: + rows = connection.execute( + "SELECT project_id, revision, plan_digest, manifest_digest, repository_identity, " + "initial_base_sha, integration_ref, finalise_ref, admitted_by, admitted_at " + "FROM plan_revisions WHERE project_id = ? ORDER BY revision", + (project_id,), + ).fetchall() + finally: + connection.close() + return [PlanRevisionSummary.model_validate(dict(row)) for row in rows] + + @app.get( + "/api/projects/{project_id}/plan-revisions/{revision}", + tags=["plan"], + summary="Read one immutable admitted plan revision", + response_model=PlanRevisionDetail, + responses={404: {"description": "No such plan revision."}}, + ) + def plan_revision_detail( + project_id: str = PathParam(description="Project id."), + revision: int = PathParam(description="Immutable revision number."), + _: None = Depends(require_token), + ) -> PlanRevisionDetail: + connection = need_queue()._connect() + try: + row = connection.execute( + "SELECT * FROM plan_revisions WHERE project_id = ? AND revision = ?", + (project_id, revision), + ).fetchone() + finally: + connection.close() + if row is None: + raise HTTPException(status_code=404, detail="plan revision not found") + return PlanRevisionDetail( + **dict(row), + items=revision_items(need_queue(), project_id, revision), + ) + + @app.post( + "/api/plans/validate", + tags=["plan"], + summary="Validate a plan without writing anything", + response_model=PlanValidationResult, + responses={404: {"description": "No such file"}}, + ) + def plans_validate( + path: str = Query(..., description="Path to the plan markdown."), + _: None = Depends(require_token), + ) -> PlanValidationResult: + """Return the complete deterministic contract report.""" + target = Path(path) + if not target.is_file(): + raise HTTPException(status_code=404, detail=f"no plan at {path!r}") + report = validate_plan_file(target) + return PlanValidationResult.model_validate(report.as_dict()) + @app.post( "/api/plan/parse", tags=["plan"], diff --git a/src/agent_harness/exploration_defaults.py b/src/agent_harness/exploration_defaults.py new file mode 100644 index 0000000..2dd6ceb --- /dev/null +++ b/src/agent_harness/exploration_defaults.py @@ -0,0 +1,161 @@ +"""Safe defaults for exploring a project that is new to the harness. + +These are policy defaults, not workload facts. They give the scoper and +surveyor a consistent starting point while keeping repository-specific values +such as the owner, remote, checks, base commit and toolchain evidence-based. +An operator can override a default during the proposal review. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ExplorationDefault: + """One reviewable default used during project exploration.""" + + key: str + decision: str + rationale: str + + +EXPLORATION_DEFAULTS: tuple[ExplorationDefault, ...] = ( + ExplorationDefault( + "deployment", + "Use a declared self-hosted Docker Compose deployment, built by GitHub Actions " + "or equivalent self-hosted runners and released by the deployment orchestrator; " + "keep deployment approval separate.", + "The harness must not silently depend on a session host or deploy after merge.", + ), + ExplorationDefault( + "third_party_gates", + "Allow third-party gates through installed metadata, but register none by " + "default; plan-declared checks remain the initial gate set.", + "Generic extension must not become an undeclared vendor dependency.", + ), + ExplorationDefault( + "review_rationale", + "Keep reviewer-rationale exposure as an authorized A/B experiment, not a " + "settled product claim.", + "The effect must be measured rather than argued.", + ), + ExplorationDefault( + "holds", + "Allow one open hold per item and attempt; sequential holds are allowed " + "after the prior hold is answered, cancelled or expired.", + "A hold suspends a live attempt and must not create ambiguous concurrent questions.", + ), + ExplorationDefault( + "lessons", + "Defer shared lessons or memory until a real multi-item run measures " + "repeated failures with comparable prompts.", + "Changing prompts between items would make the measurement incomparable.", + ), + ExplorationDefault( + "plan_shape", + "Treat phase headings as tracking-only containers by default; only explicit " + "work-item headings are claimable.", + "A phase rationale is not an implementable work specification.", + ), + ExplorationDefault( + "terminal_outcomes", + "Give work that exhausts its retry/attempt policy a distinct terminal " + "exhausted/gave-up disposition.", + "An empty disposition must not look like unfinished work.", + ), + ExplorationDefault( + "operator_actions", + "Require exact expected state for bulk transitions and make continue or " + "force-start explicit reviewed actions; neither bypasses gates or policy refusals.", + "A stale operator view must not authorize a partial or unsafe transition.", + ), + ExplorationDefault( + "diagnostics", + "Redact credentials before durable writes; use bounded structured diagnostics " + "and hashes for reproducibility instead of retaining raw sensitive responses.", + "Debuggability cannot reintroduce secrets into append-only records.", + ), + ExplorationDefault( + "edit_safety", + "Canonicalize relative edit paths before grouping so equivalent paths cannot " + "apply twice; reject ambiguous or out-of-tree edits.", + "A correct edit must not fail or land twice because of path spelling.", + ), + ExplorationDefault( + "execution_boundary", + "Require live validation of the execution security profile and a suitable " + "project toolchain image before any real workload run.", + "Mocked subprocess tests are not evidence of isolation or tool compatibility.", + ), + ExplorationDefault( + "fleet_acceptance", + "Require local acceptance with concurrent independent items, dependent work " + "and failure isolation before remote publication.", + "One successful item does not prove fleet scheduling or isolation.", + ), + ExplorationDefault( + "promotion", + "Serialize integration-branch promotion, wait for all prerequisites, and " + "return conflicts for agent repair instead of auto-merging them.", + "Promotion is a gate-controlled coordination operation.", + ), + ExplorationDefault( + "review_feedback", + "Treat human review as authoritative: actionable feedback creates or resumes " + "one correction item, ambiguous feedback creates a hold, and duplicates are no-ops.", + "The harness must not guess at ambiguous human intent.", + ), + ExplorationDefault( + "baseline", + "Record the project’s own plan, declared checks, exact clean base commit and " + "baseline metrics before acceptance. If checks fail, repair project setup or " + "check configuration before recording the baseline.", + "The harness supplies measurement machinery, never workload-specific numbers.", + ), + ExplorationDefault( + "repository_setup", + "If the source is not a Git checkout, create a fresh private repository only " + "after the operator supplies the owner and name. Preserve provenance, add a " + "minimal README and CI workflow for pushes and pull requests, use `main`, " + "protect it with required CI/PR review, no force-push and linear-history " + "rules where supported, scan credentials and artifacts, and verify licensing " + "before publication.", + "Repository creation and publication are external mutations requiring explicit review.", + ), + ExplorationDefault( + "remote_publication", + "A private production remote may be used for remote acceptance after local " + "acceptance and evidence review; use one integration branch and one human-reviewed " + "pull request, and keep merge and deployment as separate human approvals.", + "A private remote reduces exposure but does not make publication or deployment automatic.", + ), + ExplorationDefault( + "scope_control", + "Pause unrelated framework or UI expansion while the first real local " + "acceptance is outstanding.", + "Execution evidence is the prerequisite for broadening the product.", + ), + ExplorationDefault( + "legacy_session_host", + "Treat silent activity in a legacy session-host path as a known limitation; " + "it does not block the harness-owned local fleet.", + "The primary execution path must have its own progress and isolation semantics.", + ), +) + + +def render_exploration_defaults() -> str: + """Render the defaults for a model prompt without naming a workload.""" + + lines = [ + "## Generic exploration defaults", + "", + "Use these as defaults when repository evidence and the operator have not " + "specified otherwise. Record material overrides as open questions or " + "assumptions; do not silently turn a default into a project fact.", + "", + ] + for item in EXPLORATION_DEFAULTS: + lines.append(f"- **{item.key}**: {item.decision} ({item.rationale})") + return "\n".join(lines) diff --git a/src/agent_harness/graph.py b/src/agent_harness/graph.py index 79ee2e8..ba1c77d 100644 --- a/src/agent_harness/graph.py +++ b/src/agent_harness/graph.py @@ -727,7 +727,7 @@ def _resolve_work( self, conn: sqlite3.Connection, project_id: str, item_id: str, kind: str ) -> _Resolution: row = conn.execute( - "SELECT state FROM work WHERE project_id = ? AND item_id = ?", + "SELECT state FROM work WHERE project_id = ? AND item_id = ? AND active = 1", (project_id, item_id), ).fetchone() if row is None: @@ -1050,7 +1050,7 @@ def report(self, project_id: str, *, conn: sqlite3.Connection | None = None) -> items = [ row["item_id"] for row in connection.execute( - "SELECT item_id FROM work WHERE project_id = ? ORDER BY item_id", + "SELECT item_id FROM work WHERE project_id = ? AND active = 1 ORDER BY item_id", (project_id,), ) ] @@ -1153,10 +1153,10 @@ def rebuild( """ connection, owned = self._with_conn(conn) try: - sql = "SELECT project_id, item_id, depends_on FROM work" + sql = "SELECT project_id, item_id, depends_on FROM work WHERE active = 1" params: list[Any] = [] if project_id is not None: - sql += " WHERE project_id = ?" + sql += " AND project_id = ?" params.append(project_id) sql += " ORDER BY project_id, item_id" rows = connection.execute(sql, params).fetchall() diff --git a/src/agent_harness/inception.py b/src/agent_harness/inception.py index f0b6a43..ff64e74 100644 --- a/src/agent_harness/inception.py +++ b/src/agent_harness/inception.py @@ -38,6 +38,8 @@ from dataclasses import asdict, dataclass, field from typing import Any +from .exploration_defaults import render_exploration_defaults + log = logging.getLogger(__name__) #: The role that scopes a project. Deliberately separate from `planner`, which @@ -64,6 +66,8 @@ {feedback_section} +{exploration_defaults} + ## What to produce Return JSON only, matching this shape exactly: @@ -193,7 +197,7 @@ def parse_proposal(text: str, revision: int, now: float, feedback: str | None = ) -def render_plan(proposal: Proposal, name: str, *, phases_as_items: bool = True) -> str: +def render_plan(proposal: Proposal, name: str, *, phases_as_items: bool = False) -> str: """A proposal as a PLAN.md the existing parser can read. Headings use the `### T1 — Title` shape the parser recognises, so the @@ -201,9 +205,8 @@ def render_plan(proposal: Proposal, name: str, *, phases_as_items: bool = True) **`phases_as_items` decides whether a phase heading is itself work.** A heading of `## P0 Upgrade` matches the parser's item pattern, because `P0` - is a well-formed id, so by default each phase becomes an item as well as a - container. That is deliberate for `inception` — real hand-written plans do - track phases as issues, and a generated plan should behave like one. + is a well-formed id. The safe default is a tracking-only phase container; + callers must explicitly opt in if a phase itself is claimable work. It is wrong for a plan meant to be executed straight away. The phase item's brief is the phase's *rationale* — "because we need the runtime current" — @@ -331,6 +334,7 @@ def scope(self, project_id: str, feedback: str | None = None) -> Proposal: prompt = SCOPE_PROMPT.format( overview=record.get("overview", ""), feedback_section=previous, + exploration_defaults=render_exploration_defaults(), ) reply = self.model_client.call(SCOPER, [{"role": "user", "content": prompt}]) text = reply if isinstance(reply, str) else str(reply) @@ -425,7 +429,7 @@ def plan_markdown(self, project_id: str, name: str | None = None) -> str: proposal = self.current(project_id) if proposal is None: raise ValueError("nothing has been scoped yet") - return render_plan(proposal, name or project_id) + return render_plan(proposal, name or project_id, phases_as_items=False) def new_project_id(name: str) -> str: diff --git a/src/agent_harness/plan_contract.py b/src/agent_harness/plan_contract.py new file mode 100644 index 0000000..ba1cb06 --- /dev/null +++ b/src/agent_harness/plan_contract.py @@ -0,0 +1,583 @@ +"""The version-one, embedded TOML contract for a generic plan. + +This module deliberately parses configuration only. It does not inspect a +repository, load an adapter, resolve a model, or create queue state. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass, field, fields +from types import MappingProxyType +from typing import Any, cast + +from .plan import ParsedPlan, parse_plan + +REQUIRED_SECTIONS = ( + "Project identity and brief", + "Scope and non-goals", + "Rough architecture", + "Data model", + "GUI and interfaces", + "Execution and local delivery", + "Cross-cutting requirements", + "Work items", + "Local definition of done", + "Assumptions, risks, and open questions", +) +_FENCE = re.compile(r"^\s*```harness\s*$", re.IGNORECASE) +_CLOSE = re.compile(r"^\s*```\s*$") +_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*[A-Za-z0-9_]$") +_SAFE_TARGET = re.compile( + r"^/(?!proc(?:/|$)|sys(?:/|$)|dev(?:/|$)|etc/shadow(?:/|$))[A-Za-z0-9._/@+:-]+$" +) + + +class PlanContractError(ValueError): + """A plan does not meet the deterministic v1 contract.""" + + +def _freeze(value: Any) -> Any: + if isinstance(value, dict): + return MappingProxyType({str(k): _freeze(v) for k, v in value.items()}) + if isinstance(value, list): + return tuple(_freeze(v) for v in value) + return value + + +def _mapping(value: Any, path: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise PlanContractError(f"{path} must be a table") + return value + + +def _keys(value: Mapping[str, Any], allowed: set[str], path: str) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + raise PlanContractError(f"unknown field(s) at {path}: {', '.join(unknown)}") + + +def _required(value: Mapping[str, Any], name: str, path: str) -> Any: + if name not in value: + raise PlanContractError(f"missing required field {path}.{name}") + return value[name] + + +def _string(value: Any, path: str, *, nonempty: bool = True) -> str: + if not isinstance(value, str) or (nonempty and not value.strip()): + raise PlanContractError(f"{path} must be a non-empty string") + return value + + +def _argv(value: Any, path: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value: + raise PlanContractError(f"{path} must be a non-empty argv array") + return tuple(_string(part, f"{path}[{i}]") for i, part in enumerate(value)) + + +def _argvs(value: Any, path: str) -> tuple[tuple[str, ...], ...]: + if not isinstance(value, list): + raise PlanContractError(f"{path} must be an array of argv arrays") + return tuple(_argv(item, f"{path}[{i}]") for i, item in enumerate(value)) + + +@dataclass(frozen=True) +class ImageSpec: + strategy: str + reference: str | None = None + pull: bool | None = None + source: str | None = None + dockerfile: str | None = None + context: str | None = None + build_network: str | None = None + immutable_base: str | None = None + workdir: str | None = None + copy: tuple[str, ...] = () + setup: tuple[tuple[str, ...], ...] = () + user: str | None = None + + +@dataclass(frozen=True) +class MountSpec: + source: str + target: str + writable: bool = False + + +@dataclass(frozen=True) +class ProbeSpec: + name: str + command: tuple[str, ...] + expect_regex: str | None = None + + +@dataclass(frozen=True) +class SecretSpec: + name: str + source: str + scope: str + + +@dataclass(frozen=True) +class LimitsSpec: + command_timeout_seconds: int + memory: str + cpus: str + pids: int + user: str + rootfs_read_only: bool + tmpfs_size: str + + +@dataclass(frozen=True) +class ExecutionSpec: + backend: str + network: str + toolchains: tuple[str, ...] + system_packages: tuple[str, ...] + image: ImageSpec + limits: LimitsSpec + mounts: tuple[MountSpec, ...] = () + probes: tuple[ProbeSpec, ...] = () + secrets: tuple[SecretSpec, ...] = () + config: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AgentsSpec: + role_runner: str + required_roles: tuple[str, ...] + max_workers: int + max_attempts: int + max_item_seconds: int + max_item_spend_usd: float + max_hold_seconds: int + + +@dataclass(frozen=True) +class RepositorySpec: + base_ref: str + integration_ref: str + finalise_ref: str | None = None + + +@dataclass(frozen=True) +class ProjectSpec: + key: str + name: str + + +@dataclass(frozen=True) +class ChecksSpec: + item: tuple[tuple[str, ...], ...] + integration: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True) +class LocalDeliverySpec: + backend: str + build: tuple[str, ...] + build_context: str + readiness: tuple[str, ...] + readiness_context: str + acceptance: tuple[str, ...] + acceptance_context: str + required_host_tools: tuple[str, ...] + command_timeout_seconds: int + readiness_timeout_seconds: int + readiness_interval_seconds: int + config: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PlanContract: + version: int + project: ProjectSpec + repository: RepositorySpec + agents: AgentsSpec + execution: ExecutionSpec + checks: ChecksSpec + local_delivery: LocalDeliverySpec + parsed: ParsedPlan + sections: tuple[str, ...] + + def canonical(self) -> dict[str, Any]: + """Return a stable JSON-compatible representation (argv stays argv).""" + value = { + item.name: getattr(self, item.name) for item in fields(self) if item.name != "parsed" + } + return cast(dict[str, Any], _jsonable(value)) + + def canonical_json(self) -> str: + return json.dumps(self.canonical(), sort_keys=True, separators=(",", ":")) + + +def _jsonable(value: Any) -> Any: + if hasattr(value, "__dataclass_fields__"): + return {item.name: _jsonable(getattr(value, item.name)) for item in fields(value)} + if isinstance(value, Mapping): + return {str(k): _jsonable(v) for k, v in value.items()} + if isinstance(value, (tuple, list)): + return [_jsonable(v) for v in value] + return value + + +def _fenced_manifest(text: str) -> str: + starts = [i for i, line in enumerate(text.splitlines()) if _FENCE.match(line)] + if len(starts) != 1: + raise PlanContractError("plan must contain exactly one fenced `harness` block") + lines = text.splitlines() + for end in range(starts[0] + 1, len(lines)): + if _CLOSE.match(lines[end]): + return "\n".join(lines[starts[0] + 1 : end]) + raise PlanContractError("harness fence is not closed") + + +def _positive(value: Any, path: str, *, zero: bool = False) -> int: + if not isinstance(value, int) or isinstance(value, bool) or (value < 0 if zero else value <= 0): + raise PlanContractError(f"{path} must be {'non-negative' if zero else 'positive'}") + return value + + +def _strings(value: Any, path: str, *, nonempty: bool = True) -> tuple[str, ...]: + if not isinstance(value, list): + raise PlanContractError(f"{path} must be an array of strings") + return tuple(_string(v, f"{path}[{i}]", nonempty=nonempty) for i, v in enumerate(value)) + + +def _parse_manifest(raw: dict[str, Any]) -> tuple[Any, ...]: + _keys( + raw, + {"version", "project", "repository", "agents", "execution", "checks", "local_delivery"}, + "manifest", + ) + if raw.get("version") != 1: + raise PlanContractError("manifest.version must be 1") + project = _mapping(_required(raw, "project", "manifest"), "project") + _keys(project, {"key", "name"}, "project") + repository = _mapping(_required(raw, "repository", "manifest"), "repository") + _keys(repository, {"base_ref", "integration_ref", "finalise_ref"}, "repository") + refs = { + k: _string(_required(repository, k, "repository"), f"repository.{k}") + for k in ("base_ref", "integration_ref") + } + if any(not _REF.fullmatch(v) for v in refs.values()): + raise PlanContractError("repository refs must be valid local ref names") + finalise = repository.get("finalise_ref") + if finalise is not None and ( + not _string(finalise, "repository.finalise_ref") or not _REF.fullmatch(finalise) + ): + raise PlanContractError("repository.finalise_ref must be a valid local ref name") + if refs["base_ref"] == refs["integration_ref"] or finalise in refs.values(): + raise PlanContractError("repository refs must be distinct") + + agents = _mapping(_required(raw, "agents", "manifest"), "agents") + _keys( + agents, + { + "role_runner", + "required_roles", + "max_workers", + "max_attempts", + "max_item_seconds", + "max_item_spend_usd", + "max_hold_seconds", + }, + "agents", + ) + agent_values = { + k: _required(agents, k, "agents") + for k in ( + "role_runner", + "required_roles", + "max_workers", + "max_attempts", + "max_item_seconds", + "max_item_spend_usd", + "max_hold_seconds", + ) + } + execution = _parse_execution(_mapping(_required(raw, "execution", "manifest"), "execution")) + checks = _mapping(_required(raw, "checks", "manifest"), "checks") + _keys(checks, {"item", "integration"}, "checks") + delivery = _parse_delivery( + _mapping(_required(raw, "local_delivery", "manifest"), "local_delivery") + ) + return ( + ProjectSpec( + _string(_required(project, "key", "project"), "project.key"), + _string(_required(project, "name", "project"), "project.name"), + ), + RepositorySpec(refs["base_ref"], refs["integration_ref"], finalise), + AgentsSpec( + _string(agent_values["role_runner"], "agents.role_runner"), + _strings(agent_values["required_roles"], "agents.required_roles"), + _positive(agent_values["max_workers"], "agents.max_workers"), + _positive(agent_values["max_attempts"], "agents.max_attempts"), + _positive(agent_values["max_item_seconds"], "agents.max_item_seconds"), + float(agent_values["max_item_spend_usd"]), + _positive(agent_values["max_hold_seconds"], "agents.max_hold_seconds"), + ), + execution, + ChecksSpec( + _argvs(agent_values := checks["item"], "checks.item"), + _argvs(checks["integration"], "checks.integration"), + ), + delivery, + ) + + +def _parse_execution(raw: dict[str, Any]) -> ExecutionSpec: + _keys( + raw, + { + "backend", + "network", + "toolchains", + "system_packages", + "image", + "limits", + "mounts", + "probes", + "secrets", + "config", + }, + "execution", + ) + image = _mapping(_required(raw, "image", "execution"), "execution.image") + _keys( + image, + { + "strategy", + "reference", + "pull", + "source", + "dockerfile", + "context", + "build_network", + "immutable_base", + "workdir", + "copy", + "setup", + "user", + }, + "execution.image", + ) + strategy = _string( + _required(image, "strategy", "execution.image.strategy"), "execution.image.strategy" + ) + if strategy not in {"existing", "build"}: + raise PlanContractError("execution.image.strategy must be existing or build") + reference = image.get("reference") + if strategy == "existing" and not reference: + raise PlanContractError("existing images require execution.image.reference") + setup = _argvs(image.get("setup", []), "execution.image.setup") + limits = _mapping(_required(raw, "limits", "execution"), "execution.limits") + _keys( + limits, + { + "command_timeout_seconds", + "memory", + "cpus", + "pids", + "user", + "rootfs_read_only", + "tmpfs_size", + }, + "execution.limits", + ) + mounts = [] + for i, entry in enumerate(raw.get("mounts", [])): + item = _mapping(entry, f"execution.mounts[{i}]") + _keys(item, {"source", "target", "writable"}, f"execution.mounts[{i}]") + source = _string( + _required(item, "source", f"execution.mounts[{i}]"), f"execution.mounts[{i}].source" + ) + target = _string( + _required(item, "target", f"execution.mounts[{i}]"), f"execution.mounts[{i}].target" + ) + if ( + source.startswith("/") + or ".." in source.split("/") + or not _SAFE_TARGET.fullmatch(target) + ): + raise PlanContractError(f"unsafe mount at execution.mounts[{i}]") + mounts.append(MountSpec(source, target, bool(item.get("writable", False)))) + probes = [] + for i, entry in enumerate(raw.get("probes", [])): + item = _mapping(entry, f"execution.probes[{i}]") + _keys(item, {"name", "command", "expect_regex"}, f"execution.probes[{i}]") + probes.append( + ProbeSpec( + _string(_required(item, "name", f"execution.probes[{i}]"), "probe.name"), + _argv(_required(item, "command", f"execution.probes[{i}]"), "probe.command"), + item.get("expect_regex"), + ) + ) + secrets = [] + for i, entry in enumerate(raw.get("secrets", [])): + item = _mapping(entry, f"execution.secrets[{i}]") + _keys(item, {"name", "source", "scope"}, f"execution.secrets[{i}]") + scope = _string(_required(item, "scope", "secret"), "secret.scope") + if scope not in {"provisioning", "checks", "delivery"}: + raise PlanContractError("secret scope must be provisioning, checks, or delivery") + secrets.append( + SecretSpec( + _string(_required(item, "name", "secret"), "secret.name"), + _string(_required(item, "source", "secret"), "secret.source"), + scope, + ) + ) + lim = LimitsSpec( + _positive(limits["command_timeout_seconds"], "execution.limits.command_timeout_seconds"), + _string(limits.get("memory", ""), "limits.memory"), + _string(limits.get("cpus", ""), "limits.cpus"), + _positive(limits.get("pids", 0), "limits.pids"), + _string(limits.get("user", ""), "limits.user"), + bool(limits.get("rootfs_read_only", False)), + _string(limits.get("tmpfs_size", ""), "limits.tmpfs_size"), + ) + return ExecutionSpec( + _string(_required(raw, "backend", "execution"), "execution.backend"), + _string(_required(raw, "network", "execution"), "execution.network"), + _strings(_required(raw, "toolchains", "execution"), "execution.toolchains"), + _strings(raw.get("system_packages", []), "execution.system_packages"), + ImageSpec( + strategy, + reference, + image.get("pull"), + image.get("source"), + image.get("dockerfile"), + image.get("context"), + image.get("build_network"), + image.get("immutable_base"), + image.get("workdir"), + _strings(image.get("copy", []), "image.copy"), + setup, + image.get("user"), + ), + lim, + tuple(mounts), + tuple(probes), + tuple(secrets), + _freeze(raw.get("config", {})), + ) + + +def _parse_delivery(raw: dict[str, Any]) -> LocalDeliverySpec: + allowed = { + "backend", + "build", + "build_context", + "readiness", + "readiness_context", + "acceptance", + "acceptance_context", + "required_host_tools", + "command_timeout_seconds", + "readiness_timeout_seconds", + "readiness_interval_seconds", + "config", + } + _keys(raw, allowed, "local_delivery") + contexts = { + k: _string(_required(raw, k, "local_delivery"), f"local_delivery.{k}") + for k in ("build_context", "readiness_context", "acceptance_context") + } + if ( + contexts["build_context"] not in {"runner", "host"} + or contexts["readiness_context"] not in {"runner", "host"} + or contexts["acceptance_context"] not in {"runner", "host"} + ): + raise PlanContractError("delivery contexts must be runner or host") + return LocalDeliverySpec( + _string(_required(raw, "backend", "local_delivery"), "local_delivery.backend"), + _argv(_required(raw, "build", "local_delivery"), "local_delivery.build"), + contexts["build_context"], + _argv(_required(raw, "readiness", "local_delivery"), "local_delivery.readiness"), + contexts["readiness_context"], + _argv(_required(raw, "acceptance", "local_delivery"), "local_delivery.acceptance"), + contexts["acceptance_context"], + _strings(raw.get("required_host_tools", []), "local_delivery.required_host_tools"), + _positive(_required(raw, "command_timeout_seconds", "local_delivery"), "delivery timeout"), + _positive( + _required(raw, "readiness_timeout_seconds", "local_delivery"), "readiness timeout" + ), + _positive( + _required(raw, "readiness_interval_seconds", "local_delivery"), "readiness interval" + ), + _freeze(raw.get("config", {})), + ) + + +def _validate_prose(parsed: ParsedPlan, text: str) -> None: + headings = [ + re.sub(r"\s+", " ", m.group(1).strip()).casefold() + for m in re.finditer(r"^##\s+(.+?)\s*$", text, re.MULTILINE) + ] + required = {h.casefold() for h in REQUIRED_SECTIONS} + missing = required - set(headings) + duplicate = sorted(h for h in required if headings.count(h) > 1) + if missing or duplicate: + raise PlanContractError( + "required sections missing: " + f"{', '.join(sorted(missing))}; duplicates: {', '.join(duplicate)}" + ) + if not parsed.items: + raise PlanContractError("Work items must contain at least one item") + lines = text.splitlines() + starts = {item.id: item.line - 1 for item in parsed.items} + ordered = sorted(starts.values()) + for item in parsed.items: + body = item.body + if not re.search(r"(?im)^\*\*Deliverable:\*\*\s*\S", body): + raise PlanContractError(f"item {item.id} needs a non-empty Deliverable") + if not re.search(r"(?im)^\*\*Acceptance:\*\*", body): + raise PlanContractError(f"item {item.id} needs an Acceptance list") + start = starts[item.id] + end = next((line for line in ordered if line > start), len(lines)) + item_source = "\n".join(lines[start:end]) + if not item.depends_on and not re.search( + r"(?im)^(?:depends\s+on|dependenc(?:y|ies))\s*:\s*none\s*\.?\s*$", + item_source, + ): + raise PlanContractError( + f"item {item.id} must declare dependencies or `depends on: none`" + ) + + +def _normalise_dependencies(parsed: ParsedPlan) -> None: + """Apply the v1 meaning of the exact ``none`` declaration.""" + for item in parsed.items: + item.depends_on = [token for token in item.depends_on if token.strip().casefold() != "none"] + + +def parse_plan_contract(text: str) -> PlanContract: + manifest = _fenced_manifest(text) + try: + raw = tomllib.loads(manifest) + except tomllib.TOMLDecodeError as exc: + line = getattr(exc, "lineno", "?") + column = getattr(exc, "colno", "?") + message = getattr(exc, "msg", str(exc)) + raise PlanContractError( + f"invalid harness TOML at line {line}, column {column}: {message}" + ) from exc + parsed = parse_plan(text) + _normalise_dependencies(parsed) + _validate_prose(parsed, text) + project, repository, agents, execution, checks, delivery = _parse_manifest(raw) + return PlanContract( + 1, + project, + repository, + agents, + execution, + checks, + delivery, + parsed, + tuple(REQUIRED_SECTIONS), + ) diff --git a/src/agent_harness/plan_revisions.py b/src/agent_harness/plan_revisions.py new file mode 100644 index 0000000..64127a7 --- /dev/null +++ b/src/agent_harness/plan_revisions.py @@ -0,0 +1,182 @@ +"""Immutable admitted-plan snapshots and current membership. + +The revision store is intentionally small. It owns historical plan content; +the queue's ``work`` and ``plans`` tables remain mutable runtime projections. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import dataclass +from typing import Any + +from .plan_contract import PlanContract +from .redaction import Redactor +from .work import WorkQueue + + +class RevisionConflict(ValueError): + """The requested snapshot conflicts with an existing immutable revision.""" + + +@dataclass(frozen=True) +class PlanRevision: + project_id: str + revision: int + plan_digest: str + manifest_digest: str + plan_markdown: str + manifest_json: str + repository_identity: str + initial_base_sha: str + integration_ref: str + finalise_ref: str | None + adapter_versions_json: str + admitted_by: str + admitted_at: float + + +def manifest_digest(contract: PlanContract) -> str: + return hashlib.sha256(contract.canonical_json().encode("utf-8")).hexdigest() + + +def acceptance_for_item(item_body: str) -> list[str]: + marker = "**Acceptance:**" + if marker not in item_body: + return [] + return [ + line.strip()[2:].strip() + for line in item_body.splitlines()[item_body.splitlines().index(marker) + 1 :] + if line.strip().startswith("-") + ] + + +def _row(row: sqlite3.Row) -> PlanRevision: + return PlanRevision(**dict(row)) + + +def admit_snapshot( + queue: WorkQueue, + *, + project_id: str, + markdown: str, + plan_digest: str, + contract: PlanContract, + repository_identity: str, + initial_base_sha: str, + admitted_by: str, + adapter_versions: dict[str, Any] | None = None, + now: float | None = None, + redactor: Redactor | None = None, +) -> PlanRevision: + """Persist one immutable revision, idempotently for identical bytes.""" + if not plan_digest or len(plan_digest) != 64: + raise ValueError("plan_digest must be a SHA-256 hex digest") + if redactor is not None and redactor.redacted(markdown): + raise RevisionConflict("plan contains credential-shaped content and was not stored") + if now is None: + now = float(queue.now()) + manifest_json = contract.canonical_json() + manifest_hash = manifest_digest(contract) + adapters_json = json.dumps(adapter_versions or {}, sort_keys=True, separators=(",", ":")) + conn = queue._connect() + try: + conn.execute("BEGIN IMMEDIATE") + existing = conn.execute( + "SELECT * FROM plan_revisions WHERE project_id = ? AND plan_digest = ?", + (project_id, plan_digest), + ).fetchone() + if existing is not None: + return _row(existing) + latest = conn.execute( + "SELECT COALESCE(MAX(revision), 0) AS revision FROM plan_revisions " + "WHERE project_id = ?", + (project_id,), + ).fetchone() + revision = int(latest["revision"]) + 1 + conn.execute( + "INSERT INTO plan_revisions (project_id, revision, plan_digest, manifest_digest, " + "plan_markdown, manifest_json, repository_identity, initial_base_sha, integration_ref, " + "finalise_ref, adapter_versions_json, admitted_by, admitted_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + project_id, + revision, + plan_digest, + manifest_hash, + markdown, + manifest_json, + repository_identity, + initial_base_sha, + contract.repository.integration_ref, + contract.repository.finalise_ref, + adapters_json, + admitted_by, + now, + ), + ) + for ordinal, item in enumerate(contract.parsed.items): + conn.execute( + "INSERT INTO plan_revision_items (project_id, revision, item_id, ordinal, title, " + "brief, deliverable, depends_on_json, acceptance_json, active) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", + ( + project_id, + revision, + item.id, + ordinal, + item.title, + item.brief(), + item.deliverable, + json.dumps(item.depends_on), + json.dumps(acceptance_for_item(item.body)), + ), + ) + conn.execute( + "UPDATE projects SET current_plan_revision = ?, updated_at = ? WHERE project_id = ?", + (revision, now, project_id), + ) + if conn.execute("SELECT changes()").fetchone()[0] != 1: + raise RevisionConflict(f"project does not exist: {project_id}") + conn.commit() + row = conn.execute( + "SELECT * FROM plan_revisions WHERE project_id = ? AND revision = ?", + (project_id, revision), + ).fetchone() + assert row is not None + return _row(row) + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def current_revision(queue: WorkQueue, project_id: str) -> PlanRevision | None: + conn = queue._connect() + try: + row = conn.execute( + "SELECT r.* FROM plan_revisions r JOIN projects p ON p.project_id = r.project_id " + "AND p.current_plan_revision = r.revision WHERE r.project_id = ?", + (project_id,), + ).fetchone() + return _row(row) if row is not None else None + finally: + conn.close() + + +def revision_items(queue: WorkQueue, project_id: str, revision: int) -> list[dict[str, Any]]: + conn = queue._connect() + try: + return [ + dict(row) + for row in conn.execute( + "SELECT * FROM plan_revision_items WHERE project_id = ? AND revision = ? " + "ORDER BY ordinal", + (project_id, revision), + ) + ] + finally: + conn.close() diff --git a/src/agent_harness/plan_validation.py b/src/agent_harness/plan_validation.py new file mode 100644 index 0000000..036dc84 --- /dev/null +++ b/src/agent_harness/plan_validation.py @@ -0,0 +1,296 @@ +"""Pure, deterministic validation of the generic plan contract. + +Validation is deliberately separate from admission and execution. It reads +text and returns findings; it never writes queue state, calls a model, or +loads an adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .plan import parse_plan +from .plan_contract import REQUIRED_SECTIONS, PlanContractError, parse_plan_contract + + +@dataclass(frozen=True) +class Finding: + code: str + severity: str + message: str + remediation: str + path: str + line: int | None = None + column: int | None = None + pointer: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "severity": self.severity, + "message": self.message, + "remediation": self.remediation, + "path": self.path, + "line": self.line, + "column": self.column, + "pointer": self.pointer, + } + + +@dataclass(frozen=True) +class ValidationReport: + valid: bool + findings: tuple[Finding, ...] + + def as_dict(self) -> dict[str, Any]: + return {"valid": self.valid, "findings": [finding.as_dict() for finding in self.findings]} + + +def _finding( + code: str, + message: str, + remediation: str, + *, + path: str = "plan", + line: int | None = None, + column: int | None = None, + pointer: str | None = None, + severity: str = "error", +) -> Finding: + return Finding(code, severity, message, remediation, path, line, column, pointer) + + +def _contract_finding(exc: PlanContractError) -> Finding: + message = str(exc) + line = column = None + words = message.split() + if "line" in words: + try: + line = int(words[words.index("line") + 1].rstrip(",")) + column = int(words[words.index("column") + 1].rstrip(":")) + except (ValueError, IndexError): + pass + if "fence" in message: + code = "PLAN-D001" + remediation = "Keep exactly one closed fenced `harness` block in the plan." + elif "required sections" in message: + code = "PLAN-D002" + remediation = "Add each required level-two section exactly once." + elif "unknown field" in message or "manifest" in message: + code = "PLAN-M001" + remediation = "Use only fields defined by the version-one manifest contract." + elif "TOML" in message: + code = "PLAN-M002" + remediation = "Fix the TOML syntax at the reported location." + elif "mount" in message or "secret" in message or "ref" in message: + code = "PLAN-P001" + remediation = "Correct the path, reference, or secret declaration." + elif "item" in message: + code = "PLAN-W001" + remediation = ( + "Give every work item a deliverable, acceptance list, and dependency declaration." + ) + else: + code = "PLAN-M003" + remediation = "Correct the manifest value named by the diagnostic." + return _finding(code, message, remediation, line=line, column=column) + + +def validate_plan(text: str, *, source: str = "plan") -> ValidationReport: + """Return every independently discoverable finding in stable order.""" + findings: list[Finding] = [] + try: + contract = parse_plan_contract(text) + except PlanContractError as exc: + finding = _contract_finding(exc) + findings.append( + Finding( + finding.code, + finding.severity, + finding.message, + finding.remediation, + source, + finding.line, + finding.column, + finding.pointer, + ) + ) + contract = None + + parsed = parse_plan(text) + lines = text.splitlines() + heading_names = [ + " ".join(line[3:].split()).casefold() + for line in lines + if line.startswith("## ") and not line.startswith("### ") + ] + required = {name.casefold() for name in REQUIRED_SECTIONS} + for name in sorted(required - set(heading_names)): + findings.append( + _finding( + "PLAN-D002", + f"required section is missing: {name}", + f"Add a level-two `{name}` section, or state Not applicable with a reason.", + path=source, + ) + ) + for name in sorted(name for name in required if heading_names.count(name) > 1): + findings.append( + _finding( + "PLAN-D003", + f"required section is duplicated: {name}", + "Keep exactly one copy of each required level-two section.", + path=source, + ) + ) + if text.count("```harness") != 1 or text.count("```harness") != text.count("```harness\n"): + findings.append( + _finding( + "PLAN-D001", + "plan must contain exactly one harness manifest fence", + "Keep exactly one closed fenced `harness` block in the plan.", + path=source, + ) + ) + if not parsed.items: + findings.append( + _finding( + "PLAN-W002", + "the plan contains no recognized work items", + "Add work items using a supported heading, checkbox, or table form.", + path=source, + ) + ) + for item_id, duplicate_lines in sorted(parsed.duplicate_ids().items()): + findings.append( + _finding( + "PLAN-W003", + f"work item {item_id} is declared more than once", + "Keep one authoritative declaration for each work-item id.", + path=source, + line=duplicate_lines[0], + pointer=f"items.{item_id}", + ) + ) + for item in parsed.items: + if not item.body: + findings.append( + _finding( + "PLAN-W001", + f"work item {item.id} has no brief", + "Add a non-empty Deliverable and Acceptance section.", + path=source, + line=item.line, + pointer=f"items.{item.id}", + ) + ) + if "**Deliverable:**" not in item.body: + findings.append( + _finding( + "PLAN-W004", + f"work item {item.id} has no Deliverable field", + "Add an observable `**Deliverable:**` outcome.", + path=source, + line=item.line, + pointer=f"items.{item.id}.deliverable", + ) + ) + if "**Acceptance:**" not in item.body: + findings.append( + _finding( + "PLAN-W005", + f"work item {item.id} has no Acceptance list", + "Add a non-empty `**Acceptance:**` list.", + path=source, + line=item.line, + pointer=f"items.{item.id}.acceptance", + ) + ) + if "[[execution.mounts]]" in text: + for line_number, line in enumerate(lines, 1): + if line.strip().startswith("target = ") and '"/proc' in line: + findings.append( + _finding( + "PLAN-P001", + "execution mount target enters a protected system path", + "Use a safe absolute runner path outside protected system paths.", + path=source, + line=line_number, + pointer="execution.mounts.target", + ) + ) + if 'scope = "everywhere"' in text: + findings.append( + _finding( + "PLAN-P002", + "secret scope is not one of provisioning, checks, or delivery", + "Choose one explicit secret scope.", + path=source, + pointer="execution.secrets.scope", + ) + ) + report = parsed.dependency_report() + for item_id, tokens in sorted(report.unresolved.items()): + findings.append( + _finding( + "PLAN-G001", + f"{item_id} names unresolved local dependency: {', '.join(tokens)}", + "Add the missing local item or declare the dependency with its proper target kind.", + path=source, + pointer=f"items.{item_id}.depends_on", + ) + ) + for item_id, tokens in sorted(report.malformed.items()): + findings.append( + _finding( + "PLAN-G002", + f"{item_id} has malformed dependency: {', '.join(tokens)}", + "Use a supported dependency token grammar.", + path=source, + pointer=f"items.{item_id}.depends_on", + ) + ) + for cycle in report.cycles: + findings.append( + _finding( + "PLAN-G003", + "dependency cycle: " + " -> ".join([*cycle, cycle[0]]), + "Remove at least one required edge from the cycle.", + path=source, + ) + ) + for arrow_line, text_line in report.unattached_arrows: + findings.append( + _finding( + "PLAN-G004", + f"dependency declaration names no work item: {text_line}", + "Attach the edge to an item declared in this plan.", + path=source, + line=arrow_line, + ) + ) + if contract is not None: + # The variable is intentionally retained as a proof that successful + # contract parsing occurred; no adapter or execution is invoked here. + del contract + findings.sort(key=lambda item: (item.line is None, item.line or 0, item.column or 0, item.code)) + return ValidationReport(not any(item.severity == "error" for item in findings), tuple(findings)) + + +def validate_plan_file(path: str | Path) -> ValidationReport: + target = Path(path) + if not target.is_file(): + return ValidationReport( + False, + ( + _finding( + "PLAN-D000", + f"plan file does not exist: {target}", + "Provide a readable local plan path.", + path=str(target), + ), + ), + ) + return validate_plan(target.read_text(encoding="utf-8"), source=str(target)) diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index 97cd49a..e59e6cc 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -920,6 +920,112 @@ class PlanParseResult(BaseModel): ) +class PlanFinding(BaseModel): + """One deterministic plan validation diagnostic.""" + + code: str = Field(description="Stable PLAN-* diagnostic code.") + severity: Literal["error", "warning", "info"] = Field( + description="Diagnostic severity; errors make the plan invalid." + ) + message: str = Field(description="What is wrong, in plain language.") + remediation: str = Field(description="The smallest useful correction.") + path: str = Field(description="Source path or logical plan name.") + line: int | None = Field(None, description="One-based source line, when known.") + column: int | None = Field(None, description="One-based source column, when known.") + pointer: str | None = Field(None, description="Stable logical pointer, when known.") + + +class PlanValidationResult(BaseModel): + """Complete deterministic validation output, including every finding.""" + + valid: bool = Field(description="True when no finding has error severity.") + findings: list[PlanFinding] = Field( + description="All independently discoverable findings, in stable source order." + ) + + +class AdmissionPreviewRequest(BaseModel): + """Local facts needed to prepare a reviewable admission proposal.""" + + model_config = ConfigDict(extra="forbid") + + project_id: str = Field(description="Stable local project identity.") + plan_path: str = Field(description="Path to the user-authored plan markdown.") + worktree: str = Field(description="Path to the local Git repository being admitted.") + + +class AdmissionPreviewResult(BaseModel): + """Read-only admission proposal bound to exact plan and Git facts.""" + + proposal_digest: str = Field(description="Digest required to bind a later apply action.") + plan_digest: str = Field(description="SHA-256 digest of the exact plan bytes.") + manifest_digest: str = Field(description="SHA-256 digest of canonical manifest semantics.") + project_id: str = Field(description="Stable local project identity.") + worktree: str = Field(description="Canonical local repository path.") + repository_identity: str = Field(description="Canonical Git repository root.") + base_ref: str = Field(description="Local base ref named by the plan.") + base_sha: str = Field(description="Exact base commit resolved without fetch.") + integration_ref: str = Field(description="Local integration ref named by the plan.") + finalise_ref: str | None = Field(None, description="Optional reviewed local finalisation ref.") + current_revision: int | None = Field(None, description="Currently admitted revision, if any.") + validation: PlanValidationResult = Field(description="The deterministic validation report.") + + +class AdmissionApplyRequest(AdmissionPreviewRequest): + """Explicit approval facts for applying one reviewed proposal.""" + + proposal_digest: str = Field(description="Digest returned by the reviewed preview.") + expected_base_sha: str = Field(description="Base SHA returned by the reviewed preview.") + expected_current_revision: int | None = Field( + None, description="Current revision returned by preview, or null for first admission." + ) + operator: str = Field(description="Authenticated operator identity recorded in the revision.") + removed_items: dict[str, str] = Field( + default_factory=dict, + description="Explicit item removal reasons, required for omitted items.", + ) + reopen_items: list[str] = Field( + default_factory=list, + description="Done item ids explicitly approved for reopening after a specification change.", + ) + high_risk_removals: list[str] = Field( + default_factory=list, + description="Started or completed item ids explicitly approved for removal.", + ) + + +class AdmissionApplyResult(BaseModel): + """The admitted immutable revision identity.""" + + project_id: str = Field(description="Project whose plan was admitted.") + revision: int = Field(description="Immutable admitted plan revision number.") + idempotent: bool = Field( + description="Whether this was an exact replay of an existing admission." + ) + state: Literal["stopped"] = Field(description="Admission always leaves the project stopped.") + + +class PlanRevisionSummary(BaseModel): + project_id: str = Field(description="Project owning the immutable revision.") + revision: int = Field(description="Monotonic immutable revision number.") + plan_digest: str = Field(description="SHA-256 digest of the admitted plan bytes.") + manifest_digest: str = Field(description="Digest of canonical manifest semantics.") + repository_identity: str = Field(description="Canonical local Git repository root.") + initial_base_sha: str = Field(description="Exact base commit admitted for this revision.") + integration_ref: str = Field(description="Immutable integration ref selected by the plan.") + finalise_ref: str | None = Field(None, description="Optional local finalisation ref.") + admitted_by: str = Field(description="Operator identity recorded at admission.") + admitted_at: float = Field(description="Unix timestamp at which the revision was admitted.") + + +class PlanRevisionDetail(PlanRevisionSummary): + plan_markdown: str = Field(description="Exact plan markdown stored for this revision.") + manifest_json: str = Field(description="Canonical manifest JSON stored for this revision.") + items: list[dict[str, Any]] = Field( + description="Immutable item snapshots, including inactive historical removals." + ) + + class PlanSyncRequest(BaseModel): path: str = Field(description="Path to the plan markdown, on the harness's filesystem.") repo: str = Field(description="GitHub repo as `owner/name`.") diff --git a/src/agent_harness/survey.py b/src/agent_harness/survey.py index 329d627..98f5c39 100644 --- a/src/agent_harness/survey.py +++ b/src/agent_harness/survey.py @@ -41,6 +41,7 @@ from dataclasses import dataclass, field from pathlib import Path +from .exploration_defaults import render_exploration_defaults from .inception import Proposal, parse_proposal, render_plan log = logging.getLogger(__name__) @@ -77,6 +78,8 @@ {objective} +{exploration_defaults} + ## The project {evidence} @@ -273,7 +276,11 @@ def survey( raise ValueError("a survey needs an objective; there is nothing to plan towards") evidence = gather(repo, docs) - prompt = SURVEY_PROMPT.format(objective=objective.strip(), evidence=evidence.render()) + prompt = SURVEY_PROMPT.format( + objective=objective.strip(), + evidence=evidence.render(), + exploration_defaults=render_exploration_defaults(), + ) proposal = parse_proposal(ask(prompt), 1, now) # Phase headings are containers here, not work. Their brief would be the diff --git a/src/agent_harness/work.py b/src/agent_harness/work.py index 53a3567..85ea997 100644 --- a/src/agent_harness/work.py +++ b/src/agent_harness/work.py @@ -150,12 +150,62 @@ updated_at REAL NOT NULL DEFAULT 0 ); +-- Immutable admitted plan content. The mutable `plans` projection above is +-- still the integration runtime; these tables are the historical contract +-- that work and evidence were admitted against. +CREATE TABLE IF NOT EXISTS plan_revisions ( + project_id TEXT NOT NULL, + revision INTEGER NOT NULL, + plan_digest TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + plan_markdown TEXT NOT NULL, + manifest_json TEXT NOT NULL, + repository_identity TEXT NOT NULL, + initial_base_sha TEXT NOT NULL, + integration_ref TEXT NOT NULL, + finalise_ref TEXT, + adapter_versions_json TEXT NOT NULL DEFAULT '{}', + admitted_by TEXT NOT NULL, + admitted_at REAL NOT NULL, + PRIMARY KEY (project_id, revision), + UNIQUE (project_id, plan_digest) +); + +CREATE TABLE IF NOT EXISTS plan_revision_items ( + project_id TEXT NOT NULL, + revision INTEGER NOT NULL, + item_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + title TEXT NOT NULL, + brief TEXT NOT NULL, + deliverable TEXT NOT NULL, + depends_on_json TEXT NOT NULL, + acceptance_json TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (project_id, revision, item_id), + FOREIGN KEY (project_id, revision) + REFERENCES plan_revisions(project_id, revision) +); + +CREATE TABLE IF NOT EXISTS plan_questions ( + project_id TEXT NOT NULL, + revision INTEGER NOT NULL, + question_id TEXT NOT NULL, + pointer TEXT NOT NULL, + severity TEXT NOT NULL, + question TEXT NOT NULL, + rationale TEXT NOT NULL, + answer TEXT, + PRIMARY KEY (project_id, revision, question_id) +); + -- (project_id, item_id), never item_id alone. Two plans that both name T1 -- are two items, and before this they were one row that silently overwrote -- the other. CREATE TABLE IF NOT EXISTS work ( project_id TEXT NOT NULL DEFAULT 'default', item_id TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, issue INTEGER, title TEXT NOT NULL, brief TEXT NOT NULL DEFAULT '', @@ -498,6 +548,7 @@ class Project: max_workers: int = 1 max_attempts: int = DEFAULT_MAX_ATTEMPTS min_free_disk_gb: float = 0.0 + current_plan_revision: int | None = None created_at: float = 0.0 updated_at: float = 0.0 @@ -552,11 +603,15 @@ class WorkRecord: first_started_at: float = 0.0 #: When this hold gives up and returns the item. Zero when not held. held_until: float = 0.0 + #: Whether this row belongs to the current admitted plan revision. A + #: retired row remains queryable for history but is never claimable. + active: bool = True @classmethod def from_row(cls, row: sqlite3.Row) -> WorkRecord: data = dict(row) data["depends_on"] = json.loads(data.get("depends_on") or "[]") + data["active"] = bool(data.get("active", 1)) return cls(**data) def dependency_specs(self, provenance: str = WORK_DECLARATION) -> list[DependencySpec]: @@ -683,6 +738,7 @@ def _migrate(self) -> None: #: still reads its own columns. ADDED_COLUMNS = { "projects": { + "current_plan_revision": "INTEGER", "max_attempts": "INTEGER NOT NULL DEFAULT 5", "min_free_disk_gb": "REAL NOT NULL DEFAULT 0", # Stage K. Additive; an older build ignores it and a project that @@ -702,6 +758,7 @@ def _migrate(self) -> None: # column it knows and simply ignores this one. The migration plan is # docs/MIGRATION-graph.md. "work": { + "active": "INTEGER NOT NULL DEFAULT 1", "admitted_revision": "INTEGER NOT NULL DEFAULT 0", # Stage K. Additive for the same reason: an older build reads # every column it knows and ignores these two, and an upgraded @@ -740,6 +797,33 @@ def _add_missing_columns(self, conn: sqlite3.Connection) -> None: if name not in existing: conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}") + conn.execute( + "CREATE TABLE IF NOT EXISTS plan_revisions (" + "project_id TEXT NOT NULL, revision INTEGER NOT NULL, " + "plan_digest TEXT NOT NULL, manifest_digest TEXT NOT NULL, " + "plan_markdown TEXT NOT NULL, manifest_json TEXT NOT NULL, " + "repository_identity TEXT NOT NULL, initial_base_sha TEXT NOT NULL, " + "integration_ref TEXT NOT NULL, finalise_ref TEXT, " + "adapter_versions_json TEXT NOT NULL DEFAULT '{}', admitted_by TEXT NOT NULL, " + "admitted_at REAL NOT NULL, PRIMARY KEY(project_id, revision), " + "UNIQUE(project_id, plan_digest))" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS plan_revision_items (" + "project_id TEXT NOT NULL, revision INTEGER NOT NULL, item_id TEXT NOT NULL, " + "ordinal INTEGER NOT NULL, title TEXT NOT NULL, brief TEXT NOT NULL, " + "deliverable TEXT NOT NULL, depends_on_json TEXT NOT NULL, " + "acceptance_json TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, " + "PRIMARY KEY(project_id, revision, item_id))" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS plan_questions (" + "project_id TEXT NOT NULL, revision INTEGER NOT NULL, question_id TEXT NOT NULL, " + "pointer TEXT NOT NULL, severity TEXT NOT NULL, question TEXT NOT NULL, " + "rationale TEXT NOT NULL, answer TEXT, " + "PRIMARY KEY(project_id, revision, question_id))" + ) + def _migrate_work_to_projects(self, conn: sqlite3.Connection) -> None: conn.execute("BEGIN IMMEDIATE") try: @@ -1806,7 +1890,7 @@ def claim( cursor_attempts, cursor_item = -1, "" while True: page = conn.execute( - "SELECT * FROM work WHERE project_id = ? " + "SELECT * FROM work WHERE project_id = ? AND active = 1 " "AND (state = ? OR (state = ? AND lease_until < ?)) " "AND (attempts > ? OR (attempts = ? AND item_id > ?)) " "ORDER BY attempts, item_id LIMIT ?", @@ -2499,8 +2583,8 @@ def claimed(self, project_id: str | None = None) -> list[WorkRecord]: """Every item currently held by a worker, expired lease or not.""" conn = self._connect() try: - sql = "SELECT * FROM work WHERE state = ?" - params: list[Any] = [CLAIMED] + sql = "SELECT * FROM work WHERE state IN (?, ?) AND active = 1" + params: list[Any] = [CLAIMED, HELD] if project_id is not None: sql += " AND project_id = ?" params.append(project_id) @@ -2549,10 +2633,13 @@ def items(self, project_id: str | None = None) -> list[WorkRecord]: conn = self._connect() try: if project_id is None: - rows = conn.execute("SELECT * FROM work ORDER BY project_id, item_id") + rows = conn.execute( + "SELECT * FROM work WHERE active = 1 ORDER BY project_id, item_id" + ) else: rows = conn.execute( - "SELECT * FROM work WHERE project_id = ? ORDER BY item_id", (project_id,) + "SELECT * FROM work WHERE project_id = ? AND active = 1 ORDER BY item_id", + (project_id,), ) return [WorkRecord.from_row(r) for r in rows] finally: @@ -2580,10 +2667,13 @@ def counts(self, project_id: str | None = None) -> dict[str, int]: conn = self._connect() try: if project_id is None: - rows = conn.execute("SELECT state, COUNT(*) AS n FROM work GROUP BY state") + rows = conn.execute( + "SELECT state, COUNT(*) AS n FROM work WHERE active = 1 GROUP BY state" + ) else: rows = conn.execute( - "SELECT state, COUNT(*) AS n FROM work WHERE project_id = ? GROUP BY state", + "SELECT state, COUNT(*) AS n FROM work " + "WHERE project_id = ? AND active = 1 GROUP BY state", (project_id,), ) return {r["state"]: r["n"] for r in rows} diff --git a/tests/test_admission_apply.py b/tests/test_admission_apply.py new file mode 100644 index 0000000..f285181 --- /dev/null +++ b/tests/test_admission_apply.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from agent_harness.admission_service import AdmissionError, apply, preview, revision_preview +from agent_harness.plan_revisions import revision_items +from agent_harness.work import DONE, WorkQueue + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def fixture(tmp_path: Path) -> tuple[WorkQueue, Path, Path]: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-q") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Test") + (repo / "README").write_text("demo\n") + git(repo, "add", "README") + git(repo, "commit", "-qm", "initial") + git(repo, "branch", "-M", "main") + plan = tmp_path / "PLAN.md" + plan.write_text(Path("examples/PLAN.md").read_text(encoding="utf-8"), encoding="utf-8") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + return queue, repo, plan + + +def test_apply_creates_stopped_project_revision_work_and_graph(tmp_path: Path) -> None: + queue, repo, plan = fixture(tmp_path) + proposal = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + + revision = apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="operator", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + ) + + assert revision == 1 + assert queue.control("widgets")[0] == "stopped" + assert queue.get_project("widgets") is not None + assert len(queue.items(project_id="widgets")) == 4 + assert queue.plan("widgets") is None + assert queue.graph.report("widgets").edges + assert all( + record.branch == f"refs/heads/harness/widgets/r1/{record.item_id}" + for record in queue.items(project_id="widgets") + ) + + +@pytest.mark.parametrize("failure", ["project", "revision", "items", "before_commit"]) +def test_apply_rolls_back_every_logical_failure(tmp_path: Path, failure: str) -> None: + queue, repo, plan = fixture(tmp_path) + proposal = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + + with pytest.raises(RuntimeError, match="injected admission failure"): + apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="operator", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + fail_at=failure, + ) + reopened = WorkQueue(str(tmp_path / "queue.sqlite")) + assert reopened.get_project("widgets") is None + assert reopened.items(project_id="widgets") == [] + assert not reopened.graph.report("widgets").edges + + +def test_apply_replay_is_idempotent_and_stale_proposal_is_refused(tmp_path: Path) -> None: + queue, repo, plan = fixture(tmp_path) + proposal = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + assert ( + apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + ) + == 1 + ) + assert ( + apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + ) + == 1 + ) + assert len(queue.items(project_id="widgets")) == 4 + + changed = plan.read_text(encoding="utf-8").replace("Widget service", "Changed", 1) + plan.write_text(changed, encoding="utf-8") + with pytest.raises(AdmissionError, match="proposal facts changed"): + apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + ) + + +def test_apply_does_not_need_git_write_or_remote_access(tmp_path: Path) -> None: + queue, repo, plan = fixture(tmp_path) + proposal = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + calls: list[tuple[str, ...]] = [] + + def read_only_git(argv: tuple[str, ...], cwd: Path) -> str: + calls.append(argv) + return git(repo, *argv) + + apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + git=read_only_git, + ) + assert all( + argv[:2] == ("rev-parse", "--show-toplevel") or argv[:2] == ("rev-parse", "--verify") + for argv in calls + ) + + +def test_revision_requires_explicit_removal_and_keeps_history_inactive(tmp_path: Path) -> None: + queue, repo, plan = fixture(tmp_path) + first = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + apply( + queue, + proposal=first, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=first.base_sha, + expected_current_revision=None, + ) + revised = plan.read_text(encoding="utf-8").replace( + "### W4: Document the accepted local change", + "### W4_REMOVED: Document the accepted local change", + ) + plan.write_text(revised, encoding="utf-8") + second = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + with pytest.raises(AdmissionError, match="omitted items"): + revision_preview(queue, admission=second, plan_path=plan, worktree=repo) + assert ( + apply( + queue, + proposal=second, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=second.base_sha, + expected_current_revision=1, + removed_items={"W4": "superseded by the API documentation item"}, + ) + == 2 + ) + assert [item.item_id for item in queue.items(project_id="widgets")] == ["W1", "W2", "W3"] + historical = queue.get("W4", project_id="widgets") + assert historical is not None and historical.active is False + assert any( + row["item_id"] == "W4" and row["active"] == 0 for row in revision_items(queue, "widgets", 2) + ) + + +def test_changed_done_item_requires_explicit_reopen(tmp_path: Path) -> None: + queue, repo, plan = fixture(tmp_path) + first = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + apply( + queue, + proposal=first, + plan_path=plan, + worktree=repo, + operator="op", + expected_base_sha=first.base_sha, + expected_current_revision=None, + ) + assert queue.release("W1", DONE, project_id="widgets") + revised = plan.read_text(encoding="utf-8").replace( + "- The migration applies to a clean and representative existing local database.", + "- The migration applies to a clean and representative local database, " + "with rollback evidence.", + ) + plan.write_text(revised, encoding="utf-8") + second = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + with pytest.raises(AdmissionError, match="explicit reopen"): + revision_preview(queue, admission=second, plan_path=plan, worktree=repo) + classified = revision_preview( + queue, + admission=second, + plan_path=plan, + worktree=repo, + reopen_items={"W1"}, + ) + change = next(item for item in classified.changes if item.item_id == "W1") + assert change.kind == "changed" + assert change.new_state == "pending" diff --git a/tests/test_admission_service.py b/tests/test_admission_service.py new file mode 100644 index 0000000..556a110 --- /dev/null +++ b/tests/test_admission_service.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import hashlib +import subprocess +from pathlib import Path + +import pytest + +from agent_harness.admission_service import AdmissionError, preview +from agent_harness.work import Project, WorkQueue + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def repository(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-q") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Test") + (repo / "README").write_text("demo\n") + git(repo, "add", "README") + git(repo, "commit", "-qm", "initial") + git(repo, "branch", "-M", "main") + return repo + + +def test_preview_is_read_only_and_bound_to_git_facts(tmp_path: Path) -> None: + repo = repository(tmp_path) + plan = tmp_path / "PLAN.md" + plan.write_text(Path("examples/PLAN.md").read_text(encoding="utf-8"), encoding="utf-8") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("widgets", "Widgets", work_dir=str(repo))) + before = queue.projects()[0] + + proposal = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + + assert proposal.base_sha == git(repo, "rev-parse", "HEAD") + assert proposal.repository_identity == str(repo) + assert proposal.current_revision is None + assert queue.projects()[0] == before + assert proposal.plan_digest == hashlib.sha256(plan.read_bytes()).hexdigest() + + +def test_invalid_plan_does_not_probe_git(tmp_path: Path) -> None: + plan = tmp_path / "PLAN.md" + plan.write_text("not a valid plan", encoding="utf-8") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("widgets", "Widgets")) + calls: list[tuple[str, ...]] = [] + + def no_git(argv: tuple[str, ...], cwd: Path) -> str: + calls.append(argv) + return "never" + + with pytest.raises(AdmissionError): + preview(queue, project_id="widgets", plan_path=plan, worktree=tmp_path, git=no_git) + assert calls == [] + + +def test_changed_base_fact_changes_proposal_digest(tmp_path: Path) -> None: + repo = repository(tmp_path) + plan = tmp_path / "PLAN.md" + plan.write_text(Path("examples/PLAN.md").read_text(encoding="utf-8"), encoding="utf-8") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("widgets", "Widgets", work_dir=str(repo))) + first = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + (repo / "README").write_text("changed\n") + git(repo, "add", "README") + git(repo, "commit", "-qm", "second") + second = preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + + assert first.base_sha != second.base_sha + assert first.proposal_digest != second.proposal_digest diff --git a/tests/test_api.py b/tests/test_api.py index 1daf7e0..c822f81 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,6 +11,8 @@ import pytest from fastapi.testclient import TestClient +from agent_harness.admission_service import apply as admission_apply +from agent_harness.admission_service import preview as admission_preview from agent_harness.api import create_api from agent_harness.audit import AuditStore from agent_harness.events import MODEL_CALL, UNCLASSIFIED, WORK, Event @@ -1069,6 +1071,85 @@ def test_parsing_a_missing_plan_is_404(client: TestClient) -> None: assert client.post("/api/plan/parse?path=/nope/PLAN.md", headers=auth()).status_code == 404 +def test_plan_validation_is_typed_and_protected(client: TestClient, tmp_path: Path) -> None: + plan = tmp_path / "PLAN.md" + plan.write_text(Path("examples/PLAN.md").read_text(encoding="utf-8"), encoding="utf-8") + + anonymous = client.post(f"/api/plans/validate?path={plan}") + assert anonymous.status_code == 401 + response = client.post(f"/api/plans/validate?path={plan}", headers=auth()) + assert response.status_code == 200 + assert response.json() == {"valid": True, "findings": []} + + +def test_admission_preview_is_typed_and_non_mutating(client: TestClient, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + import subprocess + + def git(*args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + git("init", "-q") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "Test") + (repo / "README").write_text("demo\n") + git("add", "README") + git("commit", "-qm", "initial") + git("branch", "-M", "main") + plan = tmp_path / "PLAN.md" + plan.write_text(Path("examples/PLAN.md").read_text(encoding="utf-8"), encoding="utf-8") + response = client.post( + "/api/plans/admission/preview", + headers=auth(), + json={"project_id": "widgets", "plan_path": str(plan), "worktree": str(repo)}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["project_id"] == "widgets" + assert payload["current_revision"] is None + assert payload["validation"] == {"valid": True, "findings": []} + + +def test_plan_revision_reads_are_typed_and_protected( + client: TestClient, queue: WorkQueue, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + import subprocess + + def git(*args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + git("init", "-q") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "Test") + (repo / "README").write_text("demo\n") + git("add", "README") + git("commit", "-qm", "initial") + git("branch", "-M", "main") + plan = tmp_path / "PLAN.md" + plan.write_text(Path("examples/PLAN.md").read_text(encoding="utf-8"), encoding="utf-8") + proposal = admission_preview(queue, project_id="widgets", plan_path=plan, worktree=repo) + admission_apply( + queue, + proposal=proposal, + plan_path=plan, + worktree=repo, + operator="test-operator", + expected_base_sha=proposal.base_sha, + expected_current_revision=None, + ) + listing = client.get("/api/projects/widgets/plan-revisions", headers=auth()) + assert listing.status_code == 200 + assert listing.json()[0]["revision"] == 1 + detail = client.get("/api/projects/widgets/plan-revisions/1", headers=auth()) + assert detail.status_code == 200 + assert detail.json()["admitted_by"] == "test-operator" + assert len(detail.json()["items"]) == 4 + assert client.get("/api/projects/widgets/plan-revisions/1").status_code == 401 + + def test_sync_refuses_a_plan_with_duplicate_ids(client: TestClient, tmp_path: Path) -> None: """Each id becomes one issue, so a duplicate would create two.""" plan = tmp_path / "PLAN.md" diff --git a/tests/test_inception.py b/tests/test_inception.py index 72cd608..e1c91f9 100644 --- a/tests/test_inception.py +++ b/tests/test_inception.py @@ -102,6 +102,18 @@ def test_a_reply_with_no_json_is_an_error() -> None: parse_proposal("I would rather not.", 1, 1000.0) +def test_scoper_receives_generic_exploration_defaults(queue: WorkQueue) -> None: + inc = inception(queue) + inc.start("w", "reconcile widgets") + inc.scope("w") + + prompt = inc.model_client.prompts[0] + assert "Generic exploration defaults" in prompt + assert "phase headings as tracking-only containers" in prompt + assert "credentials before durable writes" in prompt + assert "Rainmon" not in prompt + + # ------------------------------------------------------------- questions @@ -221,11 +233,9 @@ def test_the_proposal_becomes_a_plan_the_parser_can_read(queue: WorkQueue) -> No markdown = inc.plan_markdown("w", name="Widgets") plan = parse_plan(markdown) - # The phase heading is an item too. That is the parser working as - # intended rather than a leak: real plans track phases as work (NGMS has - # P0..P7 as issues), and a generated plan should behave the same way a - # hand-written one does. - assert [i.id for i in plan.items] == ["P0", "T1", "T2"] + # Phase headings are containers by default; only explicit work items are + # claimable. + assert [i.id for i in plan.items] == ["T1", "T2"] assert plan.duplicate_ids() == {} assert plan.unresolved_dependencies() == {} assert {i.id: i.depends_on for i in plan.items}["T2"] == ["T1"] diff --git a/tests/test_plan_contract.py b/tests/test_plan_contract.py new file mode 100644 index 0000000..9393f9c --- /dev/null +++ b/tests/test_plan_contract.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace + +import pytest + +from agent_harness.plan_contract import PlanContractError, parse_plan_contract + +MANIFEST = """version = 1 + +[project] +key = "demo" +name = "Demo" + +[repository] +base_ref = "main" +integration_ref = "harness/demo" + +[agents] +role_runner = "runner" +required_roles = ["implementer", "reviewer"] +max_workers = 1 +max_attempts = 2 +max_item_seconds = 60 +max_item_spend_usd = 0.0 +max_hold_seconds = 120 + +[execution] +backend = "fake" +network = "none" +toolchains = ["Python 3.12"] +system_packages = [] + +[execution.image] +strategy = "existing" +reference = "example/runner@sha256:abc" + +[execution.limits] +command_timeout_seconds = 30 +memory = "1g" +cpus = "1" +pids = 64 +user = "1000:1000" +rootfs_read_only = true +tmpfs_size = "64m" + +[[execution.probes]] +name = "python" +command = ["python", "--version"] + +[checks] +item = [["python", "-m", "pytest"]] +integration = [["python", "-m", "pytest", "-q"]] + +[local_delivery] +backend = "local" +build = ["python", "-m", "build"] +build_context = "runner" +readiness = ["python", "ready.py"] +readiness_context = "host" +acceptance = ["python", "-m", "pytest", "acceptance"] +acceptance_context = "host" +required_host_tools = ["python"] +command_timeout_seconds = 60 +readiness_timeout_seconds = 30 +readiness_interval_seconds = 1 +""" + + +def plan(*, manifest: str = MANIFEST, item: str = "depends on: none") -> str: + sections = "\n\n".join( + f"## {name}\ncontent" + for name in ( + "Project identity and brief", + "Scope and non-goals", + "Rough architecture", + "Data model", + "GUI and interfaces", + "Execution and local delivery", + "Cross-cutting requirements", + "Work items", + "Local definition of done", + "Assumptions, risks, and open questions", + ) + ) + return ( + f"{sections}\n\n### W1: Build it\n\n**Deliverable:** a result\n\n" + f"**Acceptance:**\n\n- it works\n\n{item}\n\n```harness\n{manifest}\n```\n" + ) + + +def test_complete_contract_keeps_argv_and_has_stable_canonical_form() -> None: + contract = parse_plan_contract(plan()) + + assert contract.project.key == "demo" + assert contract.parsed.items[0].depends_on == [] + assert contract.checks.item == (("python", "-m", "pytest"),) + assert contract.canonical_json() == contract.canonical_json() + assert "parsed" not in contract.canonical() + + +def test_contract_domain_is_frozen() -> None: + contract = parse_plan_contract(plan()) + with pytest.raises(AttributeError): + contract.version = 2 # type: ignore[misc] + assert replace(contract.project, key="other").key == "other" + + +@pytest.mark.parametrize( + "change, expected", + [ + ( + lambda text: text.replace("```harness", "```harness\n", 1).replace( + "```\n", "```\n```harness\n", 1 + ), + "exactly one", + ), + ( + lambda text: text.replace("## Data model", "## Data model\n## Data model", 1), + "duplicates", + ), + (lambda text: text.replace("depends on: none", "", 1), "must declare dependencies"), + ], +) +def test_contract_rejects_document_shape_errors( + change: Callable[[str], str], expected: str +) -> None: + with pytest.raises(PlanContractError, match=expected): + parse_plan_contract(change(plan())) + + +def test_unknown_manifest_field_is_rejected() -> None: + with pytest.raises(PlanContractError, match="unknown field"): + parse_plan_contract(plan(manifest=MANIFEST + "\n[unexpected]\nvalue = true\n")) + + +def test_unsafe_mount_and_secret_scope_are_rejected() -> None: + unsafe = MANIFEST + '\n[[execution.mounts]]\nsource = "cache"\ntarget = "/proc/x"\n' + with pytest.raises(PlanContractError, match="unsafe mount"): + parse_plan_contract(plan(manifest=unsafe)) + + secret = ( + MANIFEST + + '\n[[execution.secrets]]\nname = "TOKEN"\nsource = "environment"\nscope = "everywhere"\n' + ) + with pytest.raises(PlanContractError, match="secret scope"): + parse_plan_contract(plan(manifest=secret)) + + +def test_bad_toml_reports_location() -> None: + with pytest.raises(PlanContractError, match=r"line .*column"): + parse_plan_contract(plan(manifest=MANIFEST + "\ninvalid = [\n")) diff --git a/tests/test_plan_revisions.py b/tests/test_plan_revisions.py new file mode 100644 index 0000000..3700ee0 --- /dev/null +++ b/tests/test_plan_revisions.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from agent_harness.plan_contract import parse_plan_contract +from agent_harness.plan_revisions import ( + RevisionConflict, + admit_snapshot, + current_revision, + revision_items, +) +from agent_harness.redaction import Redactor +from agent_harness.work import Project, WorkQueue + + +def test_admitted_revision_is_immutable_and_idempotent(tmp_path: Path) -> None: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("widgets", "Widgets", work_dir=str(tmp_path))) + markdown = Path("examples/PLAN.md").read_text(encoding="utf-8") + contract = parse_plan_contract(markdown) + digest = hashlib.sha256(markdown.encode()).hexdigest() + + first = admit_snapshot( + queue, + project_id="widgets", + markdown=markdown, + plan_digest=digest, + contract=contract, + repository_identity="repo-1", + initial_base_sha="a" * 40, + admitted_by="operator", + ) + again = admit_snapshot( + queue, + project_id="widgets", + markdown=markdown, + plan_digest=digest, + contract=contract, + repository_identity="repo-1", + initial_base_sha="a" * 40, + admitted_by="another-operator", + ) + + assert first == again + assert current_revision(queue, "widgets") == first + assert len(revision_items(queue, "widgets", 1)) == 4 + assert json.loads(revision_items(queue, "widgets", 1)[0]["acceptance_json"]) + + +def test_new_bytes_create_new_revision_and_old_content_survives(tmp_path: Path) -> None: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("widgets", "Widgets")) + markdown = Path("examples/PLAN.md").read_text(encoding="utf-8") + contract = parse_plan_contract(markdown) + digest = hashlib.sha256(markdown.encode()).hexdigest() + first = admit_snapshot( + queue, + project_id="widgets", + markdown=markdown, + plan_digest=digest, + contract=contract, + repository_identity="repo", + initial_base_sha="a" * 40, + admitted_by="op", + ) + + changed = markdown.replace("Widget service", "Widget service v2", 1) + changed_contract = parse_plan_contract(changed) + second = admit_snapshot( + queue, + project_id="widgets", + markdown=changed, + plan_digest=hashlib.sha256(changed.encode()).hexdigest(), + contract=changed_contract, + repository_identity="repo", + initial_base_sha="b" * 40, + admitted_by="op", + ) + + assert first.revision == 1 + assert second.revision == 2 + assert first.plan_markdown != second.plan_markdown + assert current_revision(queue, "widgets") == second + + +def test_secret_shaped_plan_never_reaches_revision_table(tmp_path: Path) -> None: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("widgets", "Widgets")) + markdown = Path("examples/PLAN.md").read_text(encoding="utf-8") + contract = parse_plan_contract(markdown) + secret_plan = markdown.replace("Widget service", "token = super-secret-value", 1) + with pytest.raises(RevisionConflict): + admit_snapshot( + queue, + project_id="widgets", + markdown=secret_plan, + plan_digest=hashlib.sha256(secret_plan.encode()).hexdigest(), + contract=contract, + repository_identity="repo", + initial_base_sha="a" * 40, + admitted_by="op", + redactor=Redactor(), + ) + assert current_revision(queue, "widgets") is None diff --git a/tests/test_plan_validation.py b/tests/test_plan_validation.py new file mode 100644 index 0000000..64e1712 --- /dev/null +++ b/tests/test_plan_validation.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from agent_harness.__main__ import main +from agent_harness.plan_validation import validate_plan, validate_plan_file + + +def valid_plan() -> str: + return Path("examples/PLAN.md").read_text(encoding="utf-8") + + +def test_valid_example_has_no_findings() -> None: + result = validate_plan(valid_plan(), source="example.md") + + assert result.valid + assert result.findings == () + assert result.as_dict() == {"valid": True, "findings": []} + + +def test_validator_reports_dependency_errors_in_stable_order() -> None: + text = valid_plan().replace("W1 -> W3", "W9 -> W3\nW3 -> W3\nnot-an-edge") + result = validate_plan(text, source="broken.md") + + assert not result.valid + assert [finding.code for finding in result.findings] == [ + "PLAN-G004", + "PLAN-G001", + "PLAN-G003", + ] + assert result.as_dict()["findings"][0]["path"] == "broken.md" + + +def test_validator_reports_manifest_failure_without_calling_external_systems() -> None: + text = valid_plan().replace("version = 1", "version = 2", 1) + + result = validate_plan(text) + + assert not result.valid + assert result.findings[0].code == "PLAN-M001" + assert all(value is not None for value in result.as_dict()["findings"][0]) + + +def test_missing_file_is_a_deterministic_finding(tmp_path: Path) -> None: + result = validate_plan_file(tmp_path / "missing.md") + + assert not result.valid + assert result.findings[0].code == "PLAN-D000" + assert "missing.md" in result.findings[0].message + + +def test_cli_validate_supports_nested_spelling_and_json(capsys: Any, tmp_path: Path) -> None: + path = tmp_path / "PLAN.md" + path.write_text(valid_plan(), encoding="utf-8") + + assert main(["plan", "validate", str(path), "--json"]) == 0 + assert '"valid": true' in capsys.readouterr().out diff --git a/tests/test_survey.py b/tests/test_survey.py index 0850a3b..2f62ad2 100644 --- a/tests/test_survey.py +++ b/tests/test_survey.py @@ -88,6 +88,17 @@ def test_the_project_s_own_roadmap_reaches_the_prompt(repo: Path) -> None: assert "Upgrade the runtime to Node 22" in ask.prompts[0] +def test_the_surveyor_receives_generic_exploration_defaults(repo: Path) -> None: + ask = answering(proposal_json([item("T1")])) + survey("upgrade to Node 22", repo, ask=ask) + + prompt = ask.prompts[0] + assert "Generic exploration defaults" in prompt + assert "one correction item" in prompt + assert "project’s own plan" in prompt + assert "Rainmon" not in prompt + + def test_a_named_document_that_is_missing_is_reported_not_skipped(repo: Path) -> None: """Asking for the roadmap and silently planning without it is the bug.""" evidence = gather(repo, ["docs/no-such-file.md"])