From 7d3f3276f8aa3ecbdde8b18d36dab7106ff36d4f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:57:29 +0100 Subject: [PATCH 001/107] feat(implement): the loop's state file, step interface and checks `abcd build ` starts the implement loop (itd-2609201916151817, spc-2609202134338445, pieces 1, 2 and 4). The checks run first and every one must pass: the key is an intent, the readiness gate says READY, no list item under `## Open Questions`, no unanswered claim section, no `held:`, the spec's `## Steps` (read through the spec store's reader) leaves a step unlanded, and no peer holds the intent (the peer listing holding it in another bucket, or a live claim in the shared run state). A refusal names the step, the check, the reason and the remedy, carries every row, and writes nothing; a peer's holding is contention, exit 3. The run lives in the checkout's local tier, `.abcd/.work.local/run//state.json`, which is never created: one strict reader, one atomic writer inside an os.Root, one advisory lock. Starting creates one lane for the first unlanded spec step and records the rest as pending; starting again while it runs names the run. `implement step` performs the current lane's next step and exits, writing the state once and only after the step succeeds, so a killed step repeats and a completed one never does. A step that hands work to an agent leaves the lane awaiting, naming the role, the brief and the receipt path; `implement receipt ` completes it only on that path and a verifying receipt. A done lane opens the next pending spec step's lane; `next_eligible_at` pauses the loop. `implement status` renders. The lane sequence (worktree, brief, implement, validate, land) is named and no body ships yet: each is refused naming the spec piece that delivers it, so the next lanes register bodies without touching the engine. The process driver (piece 3) waits on the runner intent and calls the same Advance and Receipt. The end-to-end test plays the host with fake bodies. Decisions taken: the claim sections bind at the build though the readiness gate keeps them advisory; a list item under Open Questions is a question whatever it says; a peer "holds" the intent when it holds it in another bucket or claims it; `build` again resumes rather than re-checking; the issue key is refused by name at the key check. The command page, the implement page, the brief (a new 31-build.md chapter and the implement chapter), the register, the release-gate manifest pin, the regenerated reference and surface snapshot, and the spec's Progress section move with it. The spec stays open. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/27-implement.md | 55 +- .../development/brief/04-surfaces/31-build.md | 144 +++++ .abcd/development/brief/04-surfaces/README.md | 3 +- .abcd/development/release-gate/manifest.json | 7 +- .abcd/development/release/surface.json | 44 ++ ...le-intent-from-ready-to-delivered-witho.md | 27 + commands/build.md | 86 +++ commands/implement.md | 50 +- docs/reference/cli/commands.md | 100 +++- internal/core/implement/loop/check.go | 342 +++++++++++ internal/core/implement/loop/loop.go | 535 +++++++++++++++++ internal/core/implement/loop/loop_test.go | 539 ++++++++++++++++++ internal/core/implement/loop/refusal.go | 75 +++ internal/core/implement/loop/state.go | 317 ++++++++++ internal/core/intent/questions.go | 45 ++ internal/core/intent/questions_test.go | 37 ++ internal/surface/cli/build.go | 316 ++++++++++ internal/surface/cli/build_surface_test.go | 232 ++++++++ internal/surface/cli/cli.go | 1 + internal/surface/cli/implement.go | 11 +- 20 files changed, 2945 insertions(+), 21 deletions(-) create mode 100644 .abcd/development/brief/04-surfaces/31-build.md create mode 100644 commands/build.md create mode 100644 internal/core/implement/loop/check.go create mode 100644 internal/core/implement/loop/loop.go create mode 100644 internal/core/implement/loop/loop_test.go create mode 100644 internal/core/implement/loop/refusal.go create mode 100644 internal/core/implement/loop/state.go create mode 100644 internal/core/intent/questions.go create mode 100644 internal/core/intent/questions_test.go create mode 100644 internal/surface/cli/build.go create mode 100644 internal/surface/cli/build_surface_test.go diff --git a/.abcd/development/brief/04-surfaces/27-implement.md b/.abcd/development/brief/04-surfaces/27-implement.md index 02b39500a..69bc269a9 100644 --- a/.abcd/development/brief/04-surfaces/27-implement.md +++ b/.abcd/development/brief/04-surfaces/27-implement.md @@ -6,11 +6,12 @@ makes a record the unit of exclusion between them with a claim, keeps a second session inside its bounds, and derives the comparison of the three ways of dividing work from the run log (itd-2609221656373558, spc-2609221657588816). -It is the family the implement loop extends: `implement step` and -`implement receipt` (itd-2609201916151817, decision 8) are later sub-verbs of -the same verb, and the pacing intent (itd-2609201925079472) reads the same run -state. `build` is what a person types; `implement` is what a driving session -calls. +It is also the family the implement loop is driven through (itd-2609201916151817, +decision 8): `build` is what a person types, and the loop's status, step and +receipt are sub-verbs of this verb, which a driving session calls. The loop's +own state lives in the checkout's local tier, not in the shared run state below; +[`31-build.md`](31-build.md) is its chapter. The pacing intent +(itd-2609201925079472) reads the loop's window clock. ## Sub-verbs @@ -32,6 +33,9 @@ calls. | `log` | — | shipped | | `report` | — | shipped | | `load` | — | shipped | +| `status` | — | shipped | +| `step` | — | shipped | +| `receipt` | — | shipped | ## Where the run lives @@ -198,6 +202,21 @@ as it was printed; the run's hand-written load samples share the name and carry no `triggers`. The check exits 0 on every status: it never refuses, waits or signals anything. +## The implement loop + +Three sub-verbs drive the loop a build starts, each over the run's state file in +the checkout's local tier ([`31-build.md`](31-build.md) states the file, the +checks and the step interface). The status render reads every run, or the one +named, and writes nothing. The step performs the current lane's next step and +exits; at a step that hands work to an agent it names the agent, the brief and +the receipt path, and asking again moves nothing. The receipt hands that file +back, and the step completes only when the path is the one named and its +verifier accepts it. Without a named run, the step and the receipt act on the +one run in progress in the checkout and refuse naming the runs when there are +several. Their refusals name the step, the reason and the remedy, and a pause +before the run's next eligible time, or a lock held by another invocation, is +contention at exit 3. + ## Exit codes `0` done, and every status of the load check; `2` refused (an unrecognised input, a session that has not joined, a @@ -215,7 +234,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd implement` -Sub-verbs: `abcd implement check`, `abcd implement claim`, `abcd implement join`, `abcd implement leave`, `abcd implement load`, `abcd implement log`, `abcd implement mode`, `abcd implement release`, `abcd implement report`. +Sub-verbs: `abcd implement check`, `abcd implement claim`, `abcd implement join`, `abcd implement leave`, `abcd implement load`, `abcd implement log`, `abcd implement mode`, `abcd implement receipt`, `abcd implement release`, `abcd implement report`, `abcd implement status`, `abcd implement step`. Flags: none. @@ -286,6 +305,14 @@ Sub-verbs: none. | `--session` | string | | `--window` | int | +### `abcd implement receipt` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--run` | string | + ### `abcd implement release` Sub-verbs: none. @@ -303,4 +330,20 @@ Sub-verbs: none. | `--date` | string | | `--log` | string | +### `abcd implement status` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--run` | string | + +### `abcd implement step` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--run` | string | + diff --git a/.abcd/development/brief/04-surfaces/31-build.md b/.abcd/development/brief/04-surfaces/31-build.md new file mode 100644 index 000000000..ac7b10ccb --- /dev/null +++ b/.abcd/development/brief/04-surfaces/31-build.md @@ -0,0 +1,144 @@ +# `/abcd:build` — Take One Intent From READY Towards Delivered + +`/abcd:build` is the verb a person types to have abcd build one intent with +nobody holding the run in their head (itd-2609201916151817, +spc-2609202134338445). It starts the implement loop: a loop over a state file, +not a model, where every invocation reads the state, does at most one step, +writes the state and exits. `build` is the person's word and `implement` is the +machinery's (decision 8): the steps after the start are driven through the +[`/abcd:implement`](27-implement.md) family. + +This chapter describes the part of the loop that ships: the checks, the state +file and the step interface a host session drives. The lane's own steps, from +the worktree to the landing, are named in the sequence and delivered by the +later pieces of the spec; until each lands, the loop refuses at it by name. + +## Sub-verbs + +> _Machine-checked (`surface_coverage`, spc-27): each row records the verb's +> adr-40 bucket (`lint` / `review` / `audit` / `gate`, or `—` for a +> non-assessment verb) and its existence (`shipped` / `staged`). The existence +> fact is verified against the committed command-tree snapshot in both +> directions. The bucket cell is checked for membership of the closed adr-40 +> vocabulary only: the snapshot carries no bucket field, so a bucket that is +> wrong but legal passes, and that cell stays a review-grain claim._ + +| Verb | Bucket | Status | +|---|---|---| + +## The checks + +Nothing starts until every check passes, and each is a read (criteria 1 and 2): + +- **key** — the record is an intent. The issue key (decision 10) is refused by + name until the piece that admits it lands. +- **ready** — the implement-readiness gate the intent verb reports: planned, + criteria written, the spec linked both ways and written past its stub. Its + advisory rows stay advisory. +- **open questions** — no list item under the record's `## Open Questions`. The + reader is deliberately literal: an item is a question whatever it says, and a + record that has answered its questions says so in prose and keeps the answers + in `## Decisions`. +- **claim sections** — the mechanism prompt is answered or the section absent, + and the scope conditions are recorded. The readiness gate reports both as + advisory; a run is where they bind, because an autonomous lane has nobody to + ask what the record meant. +- **hold** — the record carries no `held:`, well formed or not + (iss-2609200830076665). +- **steps** — the open spec's `## Steps`, read through the spec store's own + reader, parses and leaves at least one step unlanded. A spec listing no steps + is one step, the whole spec. +- **peers** — no peer holds the intent: no sibling worktree or local branch + holds it in a bucket other than this checkout's (a lane that shipped or + re-drafted it), read through the peer listing, and no session holds a live + claim on it in the shared run state. A copy in the same bucket is not a + holding: every branch cut from the default branch carries one. + +A refusal names the check, the reason and the remedy, carries every check's row, +and writes nothing. A peer's holding is contention rather than a fault in the +record. + +## The state file + +A run lives in the checkout's local tier, one directory per run: + +```text +.abcd/.work.local/run//state.json the run +.abcd/.work.local/run/.lock the advisory lock every mutation takes +``` + +The run id is minted through the record-id seam, so two checkouts starting runs +in one second draw distinct ids. The tier itself is never created: only a +repository abcd manages has one, so a run is managed-only by construction. Each +run directory is created one level at a time and proved real, the state file is +replaced atomically inside an `os.Root`, and the reader decodes strictly, +refusing an unknown field, another schema version, or a file stored under a run +id it does not name. + +The state holds the run's key, intent, spec and driver (the host session, by +default); the window clock the pacing intent writes (`window_started_at`, +`next_eligible_at`); the lanes opened so far; the spec steps still pending; and +the run record, one line per completed step. A lane carries its spec step and +title, its next step, what it awaits when a step has handed work to an agent, +and the footprint its steps fill in: branch, base and head, worktree, brief, +receipt and pull request. + +Starting creates one lane, for the first unlanded spec step, and records the +rest as pending. Starting again while that run is in progress creates nothing +and names the run. + +## The step interface + +A host session drives the loop one step at a time (decision 5's default). The +lane's steps run in a fixed sequence: the worktree, the brief, the implementer, +the validators, the landing. Each invocation takes the lock, reads the state, +performs the current lane's next step and writes the state once, after the step +succeeds. A step that fails, or a process killed inside one, leaves the state as +it was, so the next invocation performs that step again; a step the state +records as done is never performed twice (criterion 7). A step's body is +therefore written to find what it made last time. + +A step that hands work to an agent does not complete by itself: the lane then +awaits, naming the agent's role, the brief it is handed and the path its receipt +goes to (criterion 8). Asking again re-tells the same thing and moves nothing, +and the lane advances only when that receipt is handed back at that path and its +verifier accepts it. When a lane is done, the next pending spec step opens the +next lane, so the spec's steps land one lane at a time. Before +`next_eligible_at` the loop refuses as a pause and nothing moves (decision 2). + +A step whose body this build does not carry is refused naming the step, the lane +and the spec piece that delivers it, and the run is unchanged, ready to resume in +a build that carries it. The process driver (piece 3) is the same loop called by +a process instead of a host, starting the named agent through the runner and +handing its receipt back. + +## Exit codes + +`0` done, including a resumed start, a step that re-tells an await, and a +complete run; `2` refused, naming the step, the reason and the remedy, with +nothing written; `3` contention: a peer holds the intent, the run is paused, or +the run state is locked by another invocation. A refusal in the JSON form is its +own document before the error envelope, with the step, the check, the reason and +the remedy as fields. + +## Where this sits + +- The intent and its decisions: itd-2609201916151817; the design record: + spc-2609202134338445, whose Progress section says which pieces have landed. +- The shared run state and the claim the peers check reads: + [`27-implement.md`](27-implement.md). +- The plugin surface: `commands/build.md`. + + + +## Appendix: the shipped surface + +_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ + +### `abcd build` + +Sub-verbs: none. + +Flags: none. + + diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 16e52f892..2e11d6fb4 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -43,6 +43,7 @@ are wiring rather than user-facing surface are listed separately under | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | | 29 | `/abcd:report` | shipped | Tell abcd about a defect or propose an enhancement from a repository it manages, into an inbox in your own account | [`29-report.md`](29-report.md) | | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | +| 31 | `/abcd:build` | shipped | Start the loop that takes one READY intent to delivered, refusing while a decision is open or a peer holds it | [`31-build.md`](31-build.md) | ## How much of this table a machine keeps honest @@ -172,7 +173,7 @@ documents is then an unknown command (iss-161). One file per verb, directly unde `commands/`: -`abcd`, `ahoy`, `banlist`, `capture`, `consult`, `decide`, `disembark`, `docs`, +`abcd`, `ahoy`, `banlist`, `build`, `capture`, `consult`, `decide`, `disembark`, `docs`, `embark`, `guard`, `history`, `ideate`, `identity`, `implement`, `inbox`, `ingest`, `intent`, `launch`, `lint`, `memory`, `mode`, `peers`, `prepare-this-repo`, `reading`, `report`, `site`, `update`, `version`. diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 3e4632dba..b9691ba45 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -46,6 +46,7 @@ ".abcd/development/brief/04-surfaces/27-implement.md", ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/30-inbox.md", + ".abcd/development/brief/04-surfaces/31-build.md", ".abcd/development/brief/04-surfaces/README.md", ".abcd/development/brief/02-constraints/04-naming.md", ".abcd/development/brief/05-internals/01-agents.md", @@ -82,10 +83,10 @@ "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" } ], - "checkerCount": 40, - "promptHash": "sha256:17fac8f45b9f29a778e950908bf97a4364426832a2d27b46a3368360f9124758", + "checkerCount": 41, + "promptHash": "sha256:8eb8d9ae539ad7db301cbc7b71213c7b89f1ca1f9f3ac500405d212582df944f", "prompt": { - "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, capture, consult, decide, disembark, docs, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, report, site, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, press-release-composer,\nprinciple-distiller, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", + "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, build, capture, consult, decide, disembark, docs, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, report, site, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, press-release-composer,\nprinciple-distiller, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", "directionA": "Direction A. Read ${doc} fully. Extract every checkable claim\nabout the shipped surface (verbs, sub-verbs, flags, skill names, counts,\nfile layouts, \"abcd ships N ...\" statements) and verify each against\nreality. Return item=\"${doc}\" and the discrepancy list.", "directionB": "Direction B. The real surface \"${s.name}\" (${s.kind}) exists:\ninspect it (${s.probe}). Search the brief's surface chapters for its\ndocumented home (grep .abcd/development/brief/). If no brief row documents\nit — or the brief documents it under a wrong name/shape — that is a\ndiscrepancy. Return item=\"${s.name}\" and the discrepancy list (empty if\nproperly documented)." } diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 77ac43b01..ca75dc2f1 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -234,6 +234,11 @@ } ] }, + { + "path": "abcd build", + "hidden": false, + "flags": [] + }, { "path": "abcd capture", "hidden": false, @@ -1186,6 +1191,19 @@ } ] }, + { + "path": "abcd implement receipt", + "hidden": false, + "flags": [ + { + "name": "run", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd implement release", "hidden": false, @@ -1219,6 +1237,32 @@ } ] }, + { + "path": "abcd implement status", + "hidden": false, + "flags": [ + { + "name": "run", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd implement step", + "hidden": false, + "flags": [ + { + "name": "run", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd inbox", "hidden": false, diff --git a/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md b/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md index 8e4538377..df232754b 100644 --- a/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md +++ b/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md @@ -110,3 +110,30 @@ itd-50's fix round runs from the reviewers' findings instead; the lane's definit schema gains `handback: {kind, reason}`; the loop reads it before the validators and ends the lane with that outcome when present. `abcd build ` is the person's form; `drain` calls the same loop with the key. + +## Progress + +The run builds the scope in four lanes; this section says which pieces have +landed and which remain. The spec stays open until the last lane closes it. + +- **Landed (lane 1): pieces 1, 2 and 4.** The state file and its one reader and + one atomic writer (`internal/core/implement/loop`), under + `.abcd/.work.local/run//state.json` with the tier's advisory lock; the + step interface — `abcd build ` creates the run after the checks, + `implement step` performs one step and exits, `implement receipt ` + completes an agent step on a verified receipt, `implement status` renders — + with the lane sequence (worktree, brief, implement, validate, land) named and + every step body left to the piece that delivers it; and the checks: the + readiness gate, the open questions, the claim sections, the hold (read from + `held:`, since iss-2609200830076665 shipped), the spec's steps through the + spec store's reader, and the peers (the peer listing and the shared run's + live claims). The end-to-end test plays the host with fake step bodies. +- **Seam left, not built: piece 3**, the process driver. It waits on the runner + intent (itd-2609201916056194) and calls the same `Advance` and `Receipt`. +- **Remaining: pieces 5 to 8** (the brief, the lane's worktree, the receipt's + verifier, the validators with the itd-58 verdict invariant) and **9 to 11** + (the landing, the run record and transcripts, `--auto-plan` with its ADR). + Each registers its body in `loop.DefaultSteps`. The issue key (decision 10) + is refused by name at the key check; the lane that admits it adds the + `remedy:` field schema, drain's eligibility rule and the `handback:` report + field. `--auto-plan` is not a flag yet. diff --git a/commands/build.md b/commands/build.md new file mode 100644 index 000000000..cd6cfb83b --- /dev/null +++ b/commands/build.md @@ -0,0 +1,86 @@ +--- +name: build +description: Start the implement loop for one READY intent — the checks first, refusing while a decision is open or a peer holds the record, then a run in the checkout's local tier with one lane — and drive it one step at a time, by invoking the abcd binary. +argument-hint: "" +--- + +# `/abcd:build` + +Take one intent from READY towards delivered with the loop holding the run, not +this conversation. The run lives in a state file; every invocation reads it, +does at most one step, writes it and exits, so a session that stops, is +compacted or is killed loses nothing, and the next invocation resumes where the +last one stopped. + +## Start the run + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" build --json +``` + +The checks run first, and every one must pass: + +- `key` — the argument is an intent id. An issue id is refused: the issue key + is not built yet. +- `ready` — the intent is READY: planned, its criteria written, its spec linked + and written (the same gate `/abcd:intent` reports). +- `open_questions` — no list item under `## Open Questions`. +- `claim_sections` — the `## Mechanism` prompt is answered (or the section + absent) and the scope conditions are recorded. +- `hold` — the intent carries no `held:`. +- `steps` — the spec's `## Steps` reads, and at least one step is not landed. +- `peers` — no peer holds the intent: no sibling worktree or local branch holds + it in another bucket, and no session holds a live claim on it. + +A refusal writes nothing. It exits 2, or 3 when a peer holds the intent (back +off and take other work). Under `--json` the refusal comes as its own document +before the error envelope: `refusal.step`, `refusal.check`, `refusal.reason`, +`refusal.remedy` and every check's row in `refusal.checks`. Tell the user the +check, the reason and the remedy, and do not work around it: an open question +goes back to the planning interview, a hold to the person who placed it. + +When the checks pass, the payload names the `run_id`, the `state` file +(`.abcd/.work.local/run//state.json`), the first `lane` (the spec's first +unlanded step), the `pending` spec steps, and `next`, the move to make. The +local tier is never created: in a repository abcd does not manage the verb +refuses. Starting again while the run is in progress creates nothing and +reports `resumed: true` with the same run. + +## Drive it + +The host session drives the loop. Take one step at a time: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" implement step --json +``` + +`performed` names the step the call completed. When a step hands work to an +agent, `awaiting` names the `role` to start as a fresh agent, the `brief` to +hand it and the `receipt` path it writes to. Start that agent, and when it +returns hand the receipt back: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" implement receipt --json +``` + +The lane advances only on a receipt that verifies. Asking for a step while the +lane awaits a receipt re-tells what it awaits and moves nothing. +`"${CLAUDE_PLUGIN_ROOT}/abcd" implement status --json` renders every run, its +lanes and its record, and writes nothing. + +In this build the lane's steps are named but their bodies are not carried yet: +the first step is refused naming the spec piece that delivers it, and the run +stays as it is, ready to resume in an abcd that carries it. Report that refusal +as it is; do not make the worktree, the brief or the pull request by hand on the +run's behalf. + +**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install +provisions the binary into the plugin root, so this is the rung that fires for a +plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails +too, you are in a source checkout of this repo, where — and only there — +`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a +binary on `PATH`, run `ahoy install` through whichever rung just resolved: +`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or +`go run ./cmd/abcd ahoy install` in a source checkout. + +**User input:** $ARGUMENTS diff --git a/commands/implement.md b/commands/implement.md index 5463885a2..39f5740ec 100644 --- a/commands/implement.md +++ b/commands/implement.md @@ -1,7 +1,7 @@ --- name: implement -description: Share one autonomous run between two sessions — join it, open a window in a division mode, claim a record before opening its lane, check the second session's bounds, log the run's events, and derive the comparison of the modes, and check the machine's load before abcd's own tests start — by invoking the abcd binary. The bare form and report are read-only; the load check warns and never refuses. -argument-hint: "[join|leave|mode|claim|release|check|log|report|load] …" +description: Share one autonomous run between two sessions — join it, open a window in a division mode, claim a record before opening its lane, check the second session's bounds, log the run's events, and derive the comparison of the modes, and check the machine's load before abcd's own tests start — and drive the implement loop `/abcd:build` starts one step at a time, by invoking the abcd binary. The bare form, report and status are read-only; the load check warns and never refuses. +argument-hint: "[join|leave|mode|claim|release|check|log|report|load|status|step|receipt] …" --- # `/abcd:implement` — share a run between sessions @@ -10,13 +10,15 @@ An autonomous run is one session's by default. A second session may join it for a window, and the run divides the work one of three ways — a claim per record, whole batches per session, or the first building while the second reviews and lands — and measures which way worked. This page is the run -machinery a driving session calls; it is not the verb a person types to build -an intent. +machinery a driving session calls; the verb a person types to build an intent +is `/abcd:build`, and the implement loop it starts is driven from here (see +[Drive the implement loop](#drive-the-implement-loop)). -Everything lives in the machine-scoped run state, `~/.abcd/runs//`, +The shared run lives in the machine-scoped run state, `~/.abcd/runs//`, keyed on the repository's root commit, so sessions in different worktrees of -one repository share one run and no repository file. Nothing here ever writes -to the checkout. +one repository share one run and no repository file. The shared run never +writes to the checkout; the implement loop writes only its state file, in the +checkout's gitignored local tier. ## See where the run stands @@ -140,6 +142,40 @@ and, per session across the run, its context lines and the last `used_pct` seen. not a verdict: the run's own report names the mode it would keep and says why. Relay any `unparsed` lines; they are counted nowhere. +## Drive the implement loop + +`/abcd:build` starts a run of the implement loop in this checkout's local tier, +`.abcd/.work.local/run//state.json`, separate from the shared run state +above. Three sub-verbs drive it, each reading the state first and writing it +last: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" implement status [--run ] --json +"${CLAUDE_PLUGIN_ROOT}/abcd" implement step [--run ] --json +"${CLAUDE_PLUGIN_ROOT}/abcd" implement receipt [--run ] --json +``` + +`status` renders every run (or the one `--run` names): its lanes, each lane's +spec step and next step, what an awaiting lane waits on, the pending spec steps +and the run record. It writes nothing. + +`step` performs one step of the current lane and exits. When a step hands work +to an agent the result's `awaiting` names the `role` to start as a fresh agent, +the `brief` to hand it and the `receipt` path it writes; the lane then moves +only when `receipt` is called with that path and the receipt verifies. A step +while the lane awaits re-tells the await and moves nothing; a complete run says +`complete: true`. A step that fails leaves the state as it was, so the next call +performs it again, and a completed step is never repeated. Before the run's +`next_eligible_at` the step is refused as a pause (exit 3). + +Without `--run`, both act on the one run in progress in this checkout, and are +refused naming the runs when there are several. A refusal exits 2 (3 on a pause +or a locked state), writes nothing, and under `--json` comes as its own document +before the error envelope, naming `refusal.step`, `refusal.reason` and +`refusal.remedy`. In this build no step body is carried yet: `step` refuses the +lane's first step naming the spec piece that delivers it, and the run stays +ready to resume. Report the refusal as it is. + ## Check the machine's load ```bash diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index e280509a8..0fefd0368 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -153,6 +153,31 @@ Remove one banned-name entry from the named layer --public the committed, CI-enforced layer (.abcd/docs-lint.json) ``` +### `abcd build` + +Start a run that takes one READY intent to delivered, refusing while a decision is open + +**Usage:** `abcd build ` + +Start the implement loop for one intent. The checks run first, and every one must pass: +the intent is READY (planned, criteria written, its spec linked and written), asks no +open question, has no unanswered claim section, is not held, its spec leaves a step to +build, and no peer holds it (no sibling worktree or local branch holds it in another +bucket, and no session holds a live claim on it). A refusal names the check, the reason +and the remedy, and writes nothing. + +When the checks pass, the run is created in this checkout's local tier, +`.abcd/.work.local/run//state.json`: one lane for the spec's first unlanded step, +the other unlanded steps pending, and the run record's first line. The tier itself is +never created: only a repository abcd manages has one. Starting again while the run is +in progress creates nothing and names the run, so a killed process resumes where it +stopped. + +The run then moves one step per `abcd implement step`, driven by the host session. + +Exit 2 on a refusal, exit 3 when a peer holds the intent or the run state is locked +(back off and take other work). + ### `abcd capture` Capture issues to the ledger; bare invocation is read-only status @@ -754,11 +779,11 @@ Print the proposed correction for every drifted surface as a unified diff (write ### `abcd implement` -Share one autonomous run between sessions: join, claim a record, check the bounds, log, and compare the division modes +Share one autonomous run between sessions (join, claim, check, log, compare the modes) and drive the implement loop **Usage:** `abcd implement` -The run machinery an autonomous run calls. Every piece lives in the machine-scoped run +The run machinery an autonomous run calls. The shared run lives in the machine-scoped run state, `~/.abcd/runs//`, keyed on the repository's root commit, so sessions in different worktrees of one repository share one run and no repository file. @@ -775,6 +800,11 @@ that touches the reading corpus, no lane in a split-roles window (`check` asks b a step that is not a claim). `log` appends the run's other events, and `report` derives the comparison of the modes from the log. +`status`, `step` and `receipt` drive the implement loop `abcd build` starts, whose state +lives in this checkout's local tier: `step` performs one step and exits, naming the +agent, brief and receipt path when a step hands work to an agent, and `receipt` +completes that step once the receipt verifies. + Exit 2 on a refusal (an unrecognised input, a session that has not joined, a bound the session's role does not permit), exit 3 on contention (the record is claimed by another session, or the run state is locked): back off and take other work. @@ -951,6 +981,28 @@ mode in force is the log's last window_mode line, whoever wrote it. --window int the window's number, recorded on the line ``` +#### `abcd implement receipt` + +Hand back the receipt an agent step of an implement loop run awaits; the step completes only if it verifies + +**Usage:** `abcd implement receipt [--run ] [flags]` + +Hand back the receipt the run's awaiting lane named when its step handed work to an +agent. The path must be the one the step named. The step's verifier checks it; a +receipt that verifies completes the step and the lane moves to its next step, and one +that does not is refused naming what is missing, with the lane left where it was. A +step whose verifier this abcd does not carry is refused naming the spec piece that +delivers it. + +--run names the run; without it, the one run in progress in this checkout. Exit 2 on a +refusal, exit 3 on a locked run state. + +**Flags:** + +``` + --run string the run the receipt belongs to (run-<16 digits>); the one run in progress when omitted +``` + #### `abcd implement release` Release this session's claim on a record @@ -992,6 +1044,50 @@ By default the run's whole log is read, every day of it; --date reads one day, a --log string read this log file instead of the run's own ``` +#### `abcd implement status` + +Render the implement loop's runs in this checkout: lanes, steps, what each awaits (read-only) + +**Usage:** `abcd implement status [--run ] [flags]` + +Render the runs `abcd build` started in this checkout, or the one --run names: the +intent and spec, each lane with its spec step and next step, what an awaiting lane +waits on, the pending spec steps, and the run record. Read-only: it writes nothing +and creates nothing. Exit 2 when --run names no run. + +**Flags:** + +``` + --run string the run to render (run-<16 digits>); every run in this checkout when omitted +``` + +#### `abcd implement step` + +Perform the next step of an implement loop run and exit; at an agent step, name the agent, the brief and the receipt path + +**Usage:** `abcd implement step [--run ] [flags]` + +Perform one step of the run's current lane, write the state, and exit. At a step that +hands work to an agent, the result names the agent to start, the brief it is handed +and the path its receipt goes to; the lane then advances only on +`abcd implement receipt`, and asking for a step again re-tells the same thing and +moves nothing. When a lane is done the spec's next pending step opens the next lane. +A complete run says so. + +A step whose body this abcd does not carry is refused naming the spec piece that +delivers it, and the run is unchanged. A step that fails leaves the state as it was, +so the next invocation performs it again; a completed step is never repeated. Before +the run's next_eligible_at the step is refused as a pause. + +--run names the run; without it, the one run in progress in this checkout. Exit 2 on a +refusal, exit 3 on a pause or a locked run state. + +**Flags:** + +``` + --run string the run to step (run-<16 digits>); the one run in progress when omitted +``` + ### `abcd inbox` Read the reports managed repositories filed back to abcd, and promote one to a capture diff --git a/internal/core/implement/loop/check.go b/internal/core/implement/loop/check.go new file mode 100644 index 000000000..fd6768e43 --- /dev/null +++ b/internal/core/implement/loop/check.go @@ -0,0 +1,342 @@ +package loop + +// check.go is the pre-start checks (spec piece 4; criteria 1 and 2): the +// readiness gate, the open-question count, the claim sections, the hold, the +// spec's steps, and the peers. Every check reads; none writes. A run starts +// only when every row passes, and a refusal carries every row, so the caller +// sees the whole picture rather than the first failure. + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/implement" + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/peers" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/core/spec" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// Check names, in the fixed order Check reports them. +const ( + CheckKey = "key" + CheckReady = "ready" + CheckOpenQuestions = "open_questions" + CheckClaimSections = "claim_sections" + CheckHold = "hold" + CheckSteps = "steps" + CheckPeers = "peers" +) + +// CheckRow is one pre-start check's verdict. +type CheckRow struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail"` + Remedy string `json:"remedy,omitempty"` + // contention marks a failure that is a peer's hold, not the record's fault. + contention bool +} + +// CheckResult is every pre-start check for one key. +type CheckResult struct { + Key string `json:"key"` + Intent string `json:"intent,omitempty"` + Spec string `json:"spec,omitempty"` + OK bool `json:"ok"` + Checks []CheckRow `json:"checks"` + + // steps are the unlanded spec steps a run opens lanes for, in order. + steps []PendingStep +} + +// refusal renders a failed result as the refusal Start returns: the first +// failing row names the check, and every row rides along. +func (r CheckResult) refusal() *Refusal { + for _, c := range r.Checks { + if c.OK { + continue + } + ref := refuse("check", c.Name, "", c.Detail, c.Remedy) + ref.Contention = c.contention + ref.Checks = r.Checks + return ref + } + return nil +} + +// maxIntentBytes caps the intent read the section checks make. +const maxIntentBytes = 256 * 1024 + +// Check runs the pre-start checks for key against the checkout at repoRoot. It +// returns an error only for a fault in reading this checkout (the store will +// not load, git will not answer); a record that may not start is a result with +// OK false. +// +// The rows, in order: +// +// - key: an intent id. An issue id is decision 10's key, which a later piece +// of the spec admits with drain's eligibility rule. +// - ready: the implement-readiness gate (intent.Ready) — planned, criteria, +// the spec linked and written. Its advisory rows stay advisory here. +// - open_questions: no list item under `## Open Questions`. +// - claim_sections: no unanswered claim section — the mechanism prompt +// answered or the section absent, the scope conditions recorded. +// - hold: no `held:` on the record (iss-2609200830076665). +// - steps: the spec's `## Steps` reads, and leaves a step to build. +// - peers: no peer holds the record — no sibling worktree or local branch +// holds it in another bucket, and no session holds a live claim on it. +func Check(repoRoot, key string) (CheckResult, error) { + res := CheckResult{Key: key} + if row, ok := keyCheck(key); !ok { + res.Checks = append(res.Checks, row) + return res, nil + } else { + res.Checks = append(res.Checks, row) + } + + ready, err := intent.Ready(repoRoot, key) + if err != nil { + // A malformed or unknown id: the gate cannot judge it, and nothing below + // has a record to read. + res.Checks = append(res.Checks, CheckRow{Name: CheckReady, Detail: err.Error(), + Remedy: "name a planned intent: `abcd intent` lists them"}) + return res, nil + } + res.Intent, res.Spec = ready.IntentID, ready.SpecID + res.Checks = append(res.Checks, readyRow(ready)) + + content, err := fsutil.ReadGuarded(filepath.Join(repoRoot, filepath.FromSlash(ready.Path)), maxIntentBytes) + if err != nil { + return res, fmt.Errorf("reading %s: %w", ready.Path, err) + } + res.Checks = append(res.Checks, openQuestionsRow(ready.IntentID, string(content))) + res.Checks = append(res.Checks, claimSectionsRow(ready, string(content))) + + corpus, err := intent.Load(repoRoot) + if err != nil { + return res, err + } + it, _ := corpus.Lookup(ready.IntentID) + res.Checks = append(res.Checks, holdRow(it)) + + stepsRow, steps, err := stepsCheck(repoRoot, ready) + if err != nil { + return res, err + } + res.steps = steps + res.Checks = append(res.Checks, stepsRow) + + peersRow, err := peersCheck(repoRoot, ready) + if err != nil { + return res, err + } + res.Checks = append(res.Checks, peersRow) + + res.OK = true + for _, c := range res.Checks { + if !c.OK { + res.OK = false + } + } + return res, nil +} + +// keyCheck admits an intent id and refuses everything else by name. +func keyCheck(key string) (CheckRow, bool) { + row := CheckRow{Name: CheckKey} + switch { + case recordid.ValidIntentID(key): + row.OK = true + row.Detail = key + " is an intent" + return row, true + case strings.HasPrefix(key, "iss-"): + row.Detail = key + " is an issue: the issue key (the intent's decision 10) is not built in this abcd yet" + row.Remedy = "build an intent with `abcd build `, or fix the issue by hand" + default: + row.Detail = fmt.Sprintf("%q is not a record id this verb builds", key) + row.Remedy = "name a planned intent: `abcd build `" + } + return row, false +} + +// readyRow folds the readiness gate into one row: the first failing gating +// check names why, with its own remedy. +func readyRow(r intent.ReadyResult) CheckRow { + row := CheckRow{Name: CheckReady} + if r.Ready { + row.OK = true + row.Detail = r.IntentID + " is READY (planned, criteria written, " + r.SpecID + " linked and written)" + return row + } + for _, c := range r.Checks { + if c.OK || c.Advisory { + continue + } + row.Detail = c.Name + ": " + c.Detail + row.Remedy = c.Remedy + break + } + if row.Remedy == "" { + row.Remedy = "run `abcd intent ready " + r.IntentID + "` and settle what it reports" + } + return row +} + +// openQuestionsRow refuses a record that still asks a question. +func openQuestionsRow(id, content string) CheckRow { + row := CheckRow{Name: CheckOpenQuestions} + qs := intent.OpenQuestions(content) + if len(qs) == 0 { + row.OK = true + row.Detail = "no open question" + return row + } + row.Detail = fmt.Sprintf("%s asks %d open question(s): %s", id, len(qs), strings.Join(qs, " | ")) + row.Remedy = "answer each in the record's `## Decisions` and mark `## Open Questions` settled, through the planning interview (/abcd:intent)" + return row +} + +// claimSectionsRow refuses an unanswered claim section. The readiness gate +// reports both claim rows as advisory; a run is the point they bind, because an +// autonomous lane has nobody to ask what the record meant. +func claimSectionsRow(r intent.ReadyResult, content string) CheckRow { + row := CheckRow{Name: CheckClaimSections} + var why, remedy []string + if intent.ParseClaims(content).MechanismPrompt { + why = append(why, "the '## Mechanism' prompt is unanswered") + remedy = append(remedy, "write the falsifiable claim under '## Mechanism', or record `"+intent.NullityToken+"` alone on its line to decline it") + } + for _, c := range r.Checks { + if (c.Name == intent.CheckMechanismClaim || c.Name == intent.CheckScopeConditions) && !c.OK { + why = append(why, c.Detail) + remedy = append(remedy, c.Remedy) + } + } + if len(why) == 0 { + row.OK = true + row.Detail = "the claim sections are answered" + return row + } + row.Detail = strings.Join(why, "; ") + row.Remedy = strings.Join(remedy, "; ") + return row +} + +// holdRow refuses a held record, and a malformed hold with it: the key's +// presence is somebody's attempt at a hold, whatever its shape. +func holdRow(it intent.Intent) CheckRow { + row := CheckRow{Name: CheckHold} + switch { + case it.HeldMalformed: + row.Detail = it.ID + " carries a `held:` key in a shape no verb writes" + row.Remedy = "repair the line by hand (record-lint names it), then `abcd intent unhold " + it.ID + "`" + case it.Held != "": + row.Detail = it.ID + " is held: " + it.Held + row.Remedy = "settle the hold, then `abcd intent unhold " + it.ID + "`" + default: + row.OK = true + row.Detail = it.ID + " is not held" + } + return row +} + +// stepsCheck reads the open spec's steps through the spec store's reader: the +// unlanded steps are the lanes, one at a time; a spec listing none is one +// implicit step. A section the reader refuses is refused here, because the +// lanes are made from it (unrecognized-input-never-writes). +func stepsCheck(repoRoot string, r intent.ReadyResult) (CheckRow, []PendingStep, error) { + row := CheckRow{Name: CheckSteps} + store, err := spec.Load(repoRoot) + if err != nil { + return row, nil, err + } + sp, ok := store.Lookup(r.SpecID) + if !ok { + row.Detail = "no spec to read steps from" + row.Remedy = "link a written spec first: `abcd intent ready " + r.IntentID + "` names how" + return row, nil, nil + } + listed, err := spec.ReadSteps(repoRoot, sp) + if err != nil { + row.Detail = err.Error() + row.Remedy = "rewrite " + spec.StepsHeading + " in " + sp.Path + " as the numbered list `abcd intent ready " + r.IntentID + "` describes, or empty it to build the spec as one step" + return row, nil, nil + } + if len(listed) == 0 { + row.OK = true + row.Detail = sp.ID + " lists no steps: it is built as one step" + return row, []PendingStep{{Number: 1, Title: spec.ImplicitStepTitle}}, nil + } + var steps []PendingStep + for _, s := range spec.Unlanded(listed) { + steps = append(steps, PendingStep{Number: s.Number, Title: s.Title}) + } + if len(steps) == 0 { + row.Detail = fmt.Sprintf("every one of %s's %d step(s) is landed: nothing is left to build", sp.ID, len(listed)) + row.Remedy = "close the spec: `abcd spec close " + sp.ID + "`" + return row, nil, nil + } + row.OK = true + row.Detail = fmt.Sprintf("%s: %d of %d step(s) to build", sp.ID, len(steps), len(listed)) + return row, steps, nil +} + +// peersCheck refuses a record a peer holds, from the two places a holding is +// visible from this checkout: the peer listing (a sibling worktree or a local +// branch holding the intent in another bucket than this checkout's — a lane +// that shipped or re-drafted it), and the run's claim store (a session holding +// a live claim on it). A peer holding the record in the same bucket holds a +// copy, not the record: every branch cut from the default branch does. +func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { + row := CheckRow{Name: CheckPeers} + rep, err := peers.Scan(repoRoot) + if err != nil { + return row, err + } + var holders []string + for _, l := range rep.Locate(r.IntentID) { + if l.Folder == r.Bucket { + continue + } + who := "branch " + l.Branch + if l.Source == peers.SourceWorktree { + who = "the worktree at " + fsutil.RedactHome(l.Path) + if l.Branch != "" { + who += " (branch " + l.Branch + ")" + } + } + holders = append(holders, who+" holds it in "+l.Folder+"/") + } + if sha := gitutil.RootCommit(repoRoot); gitutil.IsFullSHA(sha) { + run, err := implement.Peek(sha) + if err != nil { + return row, err + } + claims, err := run.Claims() + if err != nil { + return row, err + } + for _, c := range claims { + if (c.Live || c.Unreadable) && recordid.SameID(c.Record, r.IntentID) { + if c.Unreadable { + holders = append(holders, "an unreadable claim file holds it") + continue + } + holders = append(holders, fmt.Sprintf("session %s claims it for lane %s", c.Session, c.Lane)) + } + } + } + if len(holders) == 0 { + row.OK = true + row.Detail = "no peer holds " + r.IntentID + return row, nil + } + row.contention = true + row.Detail = r.IntentID + " is held by a peer: " + strings.Join(holders, "; ") + row.Remedy = "take other work, or coordinate with the peer; `abcd peers` and `abcd implement` show what each holds" + return row, nil +} diff --git a/internal/core/implement/loop/loop.go b/internal/core/implement/loop/loop.go new file mode 100644 index 000000000..4e3ed19ce --- /dev/null +++ b/internal/core/implement/loop/loop.go @@ -0,0 +1,535 @@ +package loop + +// loop.go is the step interface (spec piece 2; decision 5's default driver): +// Start creates a run after the checks, Advance performs the next step and +// exits, Receipt verifies what an agent step waited on and advances, and +// Status reads. Each takes the run tier's lock, reads the state first and +// writes it last; a step that fails leaves the state exactly as it was. + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// Options are the seams tests set; the zero value is production. +type Options struct { + // Now is the clock; nil means time.Now. + Now func() time.Time + // Minter mints the run id; the zero value is production. + Minter recordid.Minter +} + +func (o Options) now() time.Time { + if o.Now != nil { + return o.Now().UTC().Truncate(time.Second) + } + return time.Now().UTC().Truncate(time.Second) +} + +// Context is what a step's body is handed: where the checkout and the run +// live, the run as it stood before the step, and the clock. +type Context struct { + RepoRoot string + // RunDir is the run's directory, relative to RepoRoot. + RunDir string + State State + Now time.Time +} + +// Outcome is what a step's body returns. A body that hands its work to an agent +// returns Await: the lane then waits on the receipt it names and the step +// completes only when Receipt verifies it. +type Outcome struct { + Await *Await + // Note is the run record's line for the step. + Note string +} + +// Handler performs one step for a lane, writing what it made into lane. It +// must be idempotent: a process killed after the body's effect and before the +// state write runs the body again on the next call, so a body finds what it +// made last time rather than making it twice. An error leaves the state as it +// was; a *Refusal error is passed to the caller as the refusal. +type Handler func(c Context, lane *Lane) (Outcome, error) + +// Verifier checks the receipt an agent step waited on. An error refuses the +// receipt and the lane stays where it was. +type Verifier func(c Context, lane *Lane, receipt string) error + +// StepDef is one step of the lane sequence: its name, the spec piece that +// delivers its body, and the body — nil when this build does not carry it. +type StepDef struct { + Name StepName + Piece int + Run Handler + Verify Verifier +} + +// Steps is the lane sequence with its bodies. +type Steps []StepDef + +func (s Steps) lookup(name StepName) (StepDef, bool) { + for _, d := range s { + if d.Name == name { + return d, true + } + } + return StepDef{}, false +} + +// Sequence is the lane's steps in the order the loop performs them: make the +// lane's worktree, render its brief, hand it to an implementer and take the +// receipt, run the validators, land it. A lane past its last step is +// StepDone. +var Sequence = []StepName{StepWorktree, StepBrief, StepImplement, StepValidate, StepLand} + +// after returns the step that follows name in Sequence. +func after(name StepName) StepName { + for i, n := range Sequence { + if n == name && i+1 < len(Sequence) { + return Sequence[i+1] + } + } + return StepDone +} + +// DefaultSteps is the lane sequence this build carries, each step with the +// spec piece that delivers its body. No body is built yet: the worktree and +// the brief, the receipt and the validators, and the landing are the next +// lanes of spc-2609202134338445, and each registers its body here. +func DefaultSteps() Steps { + return Steps{ + {Name: StepWorktree, Piece: 6}, + {Name: StepBrief, Piece: 5}, + {Name: StepImplement, Piece: 7}, + {Name: StepValidate, Piece: 8}, + {Name: StepLand, Piece: 9}, + } +} + +// StartResult is what Start returns. +type StartResult struct { + RunID string `json:"run_id"` + // State is the state file, relative to the checkout root. + State string `json:"state"` + // Resumed is true when a live run for the key already existed: the call + // created nothing and names that run. + Resumed bool `json:"resumed"` + Lane Lane `json:"lane"` + Pending []PendingStep `json:"pending"` + Checks []CheckRow `json:"checks"` + Next string `json:"next"` +} + +// StepResult is what Advance and Receipt return. +type StepResult struct { + RunID string `json:"run_id"` + Lane string `json:"lane,omitempty"` + // Performed is the step this call completed; empty when it completed none + // (the lane awaits a receipt, or the run is complete). + Performed StepName `json:"performed,omitempty"` + // Step is the lane's next step after the call. + Step StepName `json:"step,omitempty"` + // Awaiting is what the lane waits on, when it waits on an agent. + Awaiting *Await `json:"awaiting,omitempty"` + Complete bool `json:"complete"` + Next string `json:"next"` +} + +// Start runs the checks for key and, when every one passes, creates the run: +// the state file with one lane at the sequence's first step, the spec's other +// unlanded steps pending, and the record's first line. A refused check writes +// nothing. A live run for the same key in this checkout is resumed — named, not +// duplicated — so starting again after a kill loses nothing and repeats nothing. +func Start(repoRoot, key string, o Options) (StartResult, error) { + if err := tierPresent(repoRoot); err != nil { + return StartResult{}, err + } + chk, err := Check(repoRoot, key) + if err != nil { + return StartResult{}, err + } + if !chk.OK { + return StartResult{}, chk.refusal() + } + if err := fsutil.EnsureRealDirAll(repoRoot, RunRelDir, dirPerm); err != nil { + return StartResult{}, fmt.Errorf("creating %s: %w", RunRelDir, err) + } + var res StartResult + err = withLock(repoRoot, func(root *os.Root) error { + runs, err := readRuns(root) + if err != nil { + return err + } + for _, st := range runs { + if recordid.SameID(st.Key, chk.Key) && !st.Complete() { + res = startResult(st, chk, true) + return nil + } + } + id, err := freeRunID(root, o.Minter) + if err != nil { + return err + } + if err := fsutil.EnsureRealDirAll(repoRoot, runRel(id), dirPerm); err != nil { + return fmt.Errorf("creating %s: %w", runRel(id), err) + } + now := o.now() + st := State{ + SchemaVersion: SchemaVersion, + RunID: id, + Key: chk.Key, + Intent: chk.Intent, + Spec: chk.Spec, + Driver: DriverHost, + CreatedAt: now, + UpdatedAt: now, + Lanes: []Lane{}, + Pending: append([]PendingStep(nil), chk.steps...), + Record: []Entry{}, + } + openNextLane(&st) + st.Record = append(st.Record, Entry{At: now, Lane: st.Lanes[0].ID, Step: "start", + Note: fmt.Sprintf("checks passed; %s opened for step %d of %s (%s)", st.Lanes[0].ID, st.Lanes[0].SpecStep, st.Spec, st.Lanes[0].StepTitle)}) + if err := writeState(root, st); err != nil { + return err + } + res = startResult(st, chk, false) + return nil + }) + return res, err +} + +func startResult(st State, chk CheckResult, resumed bool) StartResult { + res := StartResult{RunID: st.RunID, State: StateRelPath(st.RunID), Resumed: resumed, + Pending: st.Pending, Checks: chk.Checks} + if i := st.current(); i >= 0 { + res.Lane = st.Lanes[i] + res.Next = nextMove(st, st.Lanes[i]) + } + if res.Pending == nil { + res.Pending = []PendingStep{} + } + return res +} + +// openNextLane opens a lane for the first pending spec step. It is state-only: +// the lane's first step is what makes anything. +func openNextLane(st *State) { + if len(st.Pending) == 0 { + return + } + p := st.Pending[0] + st.Pending = st.Pending[1:] + st.Lanes = append(st.Lanes, Lane{ + ID: fmt.Sprintf("lane-%d", len(st.Lanes)+1), + Key: st.Key, + SpecStep: p.Number, + StepTitle: p.Title, + Step: Sequence[0], + }) +} + +// Advance performs the next step of the run's current lane and returns. A lane +// that awaits a receipt performs nothing and re-tells what it awaits; a run +// that is complete says so; a run paused by its window clock is refused until +// next_eligible_at. A step whose body this build does not carry is refused +// naming the piece that delivers it. The state is written only after a body +// succeeds, and then once. +func Advance(repoRoot, runID string, steps Steps, o Options) (StepResult, error) { + var res StepResult + err := mutate(repoRoot, runID, func(root *os.Root, st *State) (bool, error) { + now := o.now() + if st.NextEligibleAt != nil && now.Before(*st.NextEligibleAt) { + return false, contend("pause", "", "", "the run is paused until "+st.NextEligibleAt.UTC().Format(time.RFC3339), + "run the step again at or after that time") + } + i := st.current() + if i < 0 { + res = StepResult{RunID: st.RunID, Complete: true, Next: "nothing: every lane of " + st.RunID + " is done"} + return false, nil + } + lane := st.Lanes[i] + if lane.Awaiting != nil { + res = laneResult(*st, lane, "") + return false, nil + } + def, ok := steps.lookup(lane.Step) + if !ok || def.Run == nil { + piece := "" + if ok { + piece = fmt.Sprintf(" (piece %d of %s delivers it)", def.Piece, specOf(*st)) + } + return false, refusef(string(lane.Step), lane.ID, + "use an abcd that carries the step; the run is unchanged and resumes here", + "the %s step is not built in this abcd%s", lane.Step, piece) + } + c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now} + out, err := def.Run(c, &lane) + if err != nil { + return false, err + } + performed := StepName("") + if out.Await != nil { + if out.Await.Since.IsZero() { + out.Await.Since = now + } + lane.Awaiting = out.Await + note := out.Note + if note == "" { + note = "awaiting the " + out.Await.Role + "'s receipt at " + out.Await.Receipt + } + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Step: string(lane.Step), Note: note}) + } else { + performed = lane.Step + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Step: string(lane.Step), Note: out.Note}) + lane.Step = after(lane.Step) + } + st.Lanes[i] = lane + if lane.Step == StepDone { + openNextLane(st) + } + st.UpdatedAt = now + res = laneResult(*st, lane, performed) + return true, nil + }) + return res, err +} + +// Receipt hands back the receipt an agent step waited on. It is refused when no +// lane awaits one, when the path is not the one the step named, when this build +// carries no verifier for the step, and when the verifier refuses it; in every +// refusal the lane stays where it was. A verified receipt completes the step. +func Receipt(repoRoot, runID, receipt string, steps Steps, o Options) (StepResult, error) { + var res StepResult + err := mutate(repoRoot, runID, func(root *os.Root, st *State) (bool, error) { + now := o.now() + i := st.current() + if i < 0 || st.Lanes[i].Awaiting == nil { + return false, refuse("receipt", "", "", "no lane of "+st.RunID+" awaits a receipt", + "run `abcd implement step`; it names the receipt when a step hands work to an agent") + } + lane := st.Lanes[i] + if !samePath(repoRoot, receipt, lane.Awaiting.Receipt) { + return false, refuse("receipt", "", lane.ID, "the "+string(lane.Step)+" step awaits its receipt at "+lane.Awaiting.Receipt+", not at the path given", + "hand back `abcd implement receipt "+lane.Awaiting.Receipt+"`") + } + def, ok := steps.lookup(lane.Step) + if !ok || def.Verify == nil { + return false, refusef("receipt", lane.ID, "use an abcd that carries the verifier; the lane still awaits the receipt", + "the %s step's receipt verifier is not built in this abcd (piece %d of %s delivers it)", lane.Step, def.Piece, specOf(*st)) + } + c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now} + if err := def.Verify(c, &lane, lane.Awaiting.Receipt); err != nil { + if _, ok := AsRefusal(err); ok { + return false, err + } + return false, refuse("receipt", "", lane.ID, err.Error(), "correct what the reason names, then hand the receipt back") + } + performed := lane.Step + lane.Receipt = lane.Awaiting.Receipt + lane.Awaiting = nil + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Step: "receipt", + Note: "the " + string(performed) + " step's receipt verified at " + lane.Receipt}) + lane.Step = after(lane.Step) + st.Lanes[i] = lane + if lane.Step == StepDone { + openNextLane(st) + } + st.UpdatedAt = now + res = laneResult(*st, lane, performed) + return true, nil + }) + return res, err +} + +// laneResult reports where a lane stands after a call. +func laneResult(st State, lane Lane, performed StepName) StepResult { + res := StepResult{RunID: st.RunID, Lane: lane.ID, Performed: performed, Step: lane.Step, Awaiting: lane.Awaiting} + if st.Complete() { + res.Complete = true + res.Next = "nothing: every lane of " + st.RunID + " is done" + return res + } + if i := st.current(); i >= 0 { + res.Next = nextMove(st, st.Lanes[i]) + } + return res +} + +// nextMove is the one sentence a caller is told to do next. +func nextMove(st State, lane Lane) string { + if lane.Awaiting != nil { + return fmt.Sprintf("start a fresh %s agent with the brief %s; when it has written its receipt, run `abcd implement receipt %s`", + lane.Awaiting.Role, lane.Awaiting.Brief, lane.Awaiting.Receipt) + } + return fmt.Sprintf("run `abcd implement step` to take %s's %s step", lane.ID, lane.Step) +} + +// specOf names the spec a run builds against, for a refusal. +func specOf(st State) string { + if st.Spec != "" { + return st.Spec + } + return "the spec" +} + +// samePath reports whether two paths name the same file, a relative one read +// against the checkout root. +func samePath(repoRoot, a, b string) bool { + abs := func(p string) string { + if !filepath.IsAbs(p) { + p = filepath.Join(repoRoot, filepath.FromSlash(p)) + } + return filepath.Clean(p) + } + return a != "" && abs(a) == abs(b) +} + +// Runs lists this checkout's runs, oldest first. An absent tier or run +// directory holds none. A state file that cannot be read fails the listing, +// naming it: a run the loop cannot read is not one it may skip past. +func Runs(repoRoot string) ([]State, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return nil, fmt.Errorf("opening the checkout to read the run state: %w", err) + } + defer root.Close() + return readRuns(root) +} + +func readRuns(root *os.Root) ([]State, error) { + ids, err := runIDs(root) + if err != nil { + return nil, err + } + out := []State{} + for _, id := range ids { + st, err := readStateIn(root, id) + if err != nil { + return nil, err + } + out = append(out, st) + } + return out, nil +} + +// Resolve names the run a call addresses. An explicit id is checked for shape +// and presence; no id addresses the one run in this checkout that is not +// complete, and is refused naming them when there are several, or none. +func Resolve(repoRoot, runID string) (string, error) { + if runID != "" { + if _, err := ReadState(repoRoot, runID); err != nil { + return "", err + } + return runID, nil + } + runs, err := Runs(repoRoot) + if err != nil { + return "", err + } + var live []string + for _, st := range runs { + if !st.Complete() { + live = append(live, st.RunID+" ("+st.Key+")") + } + } + switch len(live) { + case 0: + return "", refuse("state", "", "", "no run in this checkout is in progress", "start one with `abcd build `") + case 1: + for _, st := range runs { + if !st.Complete() { + return st.RunID, nil + } + } + } + return "", refuse("state", "", "", fmt.Sprintf("%d runs are in progress: %v", len(live), live), "name one with --run") +} + +// tierPresent refuses a checkout without the local tier, and never creates it: +// only a repository abcd manages has one. A symlink standing in for it is +// refused by the same test. +func tierPresent(repoRoot string) error { + fi, err := os.Lstat(filepath.Join(repoRoot, filepath.FromSlash(TierRelDir))) + if err != nil || !fi.IsDir() { + return refuse("state", "", "", TierRelDir+"/ is not a directory in this checkout, so there is nowhere for a run to live", + "run abcd in a repository it manages (`abcd ahoy` sets one up); the tier is never created on the way to a write") + } + return nil +} + +// withLock runs fn holding the run tier's advisory lock, with the checkout +// opened as an os.Root for fn's reads and writes. +func withLock(repoRoot string, fn func(root *os.Root) error) error { + lock := filepath.Join(repoRoot, filepath.FromSlash(RunRelDir), lockFileName) + err := fsutil.WithFileLock(lock, lockTimeout, func() error { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return fmt.Errorf("opening the checkout: %w", err) + } + defer root.Close() + return fn(root) + }) + if errors.Is(err, fsutil.ErrLockContention) { + return contend("state", "", "", "another invocation is changing this checkout's runs", "back off and retry") + } + return err +} + +// mutate is every in-run mutation's frame: resolve nothing, check the tier, +// take the lock, read the state, let fn change it, and write it only when fn +// says it changed. +func mutate(repoRoot, runID string, fn func(root *os.Root, st *State) (bool, error)) error { + if err := tierPresent(repoRoot); err != nil { + return err + } + if !ValidRunID(runID) { + _, err := ReadState(repoRoot, runID) + return err + } + if !fsutil.IsRealDir(filepath.Join(repoRoot, filepath.FromSlash(runRel(runID)))) { + return refuse("state", "", "", "no run "+runID+" in this checkout", + "name a run `abcd implement status` lists, or start one with `abcd build `") + } + return withLock(repoRoot, func(root *os.Root) error { + st, err := readStateIn(root, runID) + if err != nil { + return err + } + changed, err := fn(root, &st) + if err != nil || !changed { + return err + } + return writeState(root, st) + }) +} + +// runIDDraws bounds the redraws freeRunID makes before it refuses. +const runIDDraws = 8 + +// freeRunID mints a run id whose directory does not exist yet. The mint reads +// no maximum (adr-45), so two starts in one second can draw one suffix; the +// coincidence is absorbed here by redrawing. +func freeRunID(root *os.Root, m recordid.Minter) (string, error) { + var last string + for range runIDDraws { + id, err := m.Mint(RunIDFamily) + if err != nil { + return "", err + } + if _, err := root.Lstat(runRel(id)); errors.Is(err, os.ErrNotExist) { + return id, nil + } else if err != nil { + return "", fmt.Errorf("checking %s: %w", runRel(id), err) + } + last = id + } + return "", fmt.Errorf("%d draws in a row named a run directory that already exists (last %s)", runIDDraws, last) +} diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go new file mode 100644 index 000000000..278456fa3 --- /dev/null +++ b/internal/core/implement/loop/loop_test.go @@ -0,0 +1,539 @@ +package loop + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/implement" + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/gittest" +) + +const ( + plannedRel = ".abcd/development/intents/planned/itd-10-alpha.md" + specRel = ".abcd/development/specs/open/spc-1-alpha.md" +) + +// readyIntent is a planned intent every check passes: criteria, conditions +// declined, no open question, no hold, linked to spc-1. +func readyIntent(extraFrontmatter, body string) string { + return "---\nid: itd-10\nslug: alpha\nspec_id: spc-1\nkind: standalone\n" + extraFrontmatter + "---\n# alpha\n\n" + + "## Mechanism\n\nWe expect it to work because it is small; shown wrong if it is not.\n\n" + + "## Scope Conditions\n\nNone stated.\n\n## Acceptance Criteria\n\n- Given x, when y, then z.\n\n" + + body + + "## Grounds\n\n- pursued: we expect the loop to run the lane end to end; shown wrong if a step needs a human\n" +} + +const settledQuestions = "## Open Questions\n\n_None open._\n\n" + +// specWithSteps is the open spec, listing steps when steps is non-empty. +func specWithSteps(steps string) string { + s := "---\nid: spc-1\nslug: alpha\nintent: itd-10\n---\n# alpha\n\n## Summary\n\nA written design record.\n" + if steps != "" { + s += "\n## Steps\n\n" + steps + } + return s +} + +// loopRepo stands up a committed repository with a READY intent, the local +// tier the run lives in, and a temporary HOME (the peer claims live there). +func loopRepo(t *testing.T, intentBody, spec string) *gittest.Repo { + t.Helper() + t.Setenv("HOME", t.TempDir()) + repo := gittest.NewRepo(t) + repo.Write(".gitignore", ".abcd/.work.local/\n") + repo.Write(plannedRel, intentBody) + repo.Write(specRel, spec) + repo.Commit("init") + if err := os.MkdirAll(filepath.Join(repo.Root(), ".abcd", ".work.local"), 0o755); err != nil { + t.Fatal(err) + } + return repo +} + +func runTierAbsent(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(RunRelDir))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a refused start must write no state, but %s exists (%v)", RunRelDir, err) + } +} + +func mustRefusal(t *testing.T, err error) *Refusal { + t.Helper() + r, ok := AsRefusal(err) + if !ok { + t.Fatalf("want a refusal, got %v", err) + } + if r.Step == "" || r.Reason == "" || r.Remedy == "" { + t.Fatalf("a refusal names the step, the reason and the remedy: %+v", r) + } + return r +} + +// TestStartRefusesEachFailedCheckAndWritesNoState is criterion 1: an intent not +// in planned/, or READY with an open question, an unanswered claim section or a +// hold, is refused naming the check that failed, and no state is written. +func TestStartRefusesEachFailedCheckAndWritesNoState(t *testing.T) { + cases := []struct { + name string + key string + intentRel string + intent string + spec string + check string + reason string + }{ + {"an issue key", "iss-2609010000001234", plannedRel, readyIntent("", settledQuestions), specWithSteps(""), CheckKey, "issue"}, + {"not an id", "itd-x", plannedRel, readyIntent("", settledQuestions), specWithSteps(""), CheckKey, "itd-x"}, + {"unknown intent", "itd-99", plannedRel, readyIntent("", settledQuestions), specWithSteps(""), CheckReady, "itd-99"}, + {"a draft", "itd-10", ".abcd/development/intents/drafts/itd-10-alpha.md", readyIntent("", settledQuestions), specWithSteps(""), CheckReady, "draft"}, + {"an open question", "itd-10", plannedRel, readyIntent("", "## Open Questions\n\n- Which runner?\n\n"), specWithSteps(""), CheckOpenQuestions, "Which runner?"}, + {"an unanswered mechanism prompt", "itd-10", plannedRel, + strings.Replace(readyIntent("", settledQuestions), "We expect it to work because it is small; shown wrong if it is not.", + intent.MechanismPrompt, 1), + specWithSteps(""), CheckClaimSections, "Mechanism"}, + {"an unrecorded scope condition", "itd-10", plannedRel, + strings.Replace(readyIntent("", settledQuestions), "## Scope Conditions\n\nNone stated.\n\n", "", 1), + specWithSteps(""), CheckClaimSections, "Scope Conditions"}, + {"a hold", "itd-10", plannedRel, readyIntent("held: \"awaiting the pacing ruling\"\n", settledQuestions), specWithSteps(""), CheckHold, "awaiting the pacing ruling"}, + {"every step landed", "itd-10", plannedRel, readyIntent("", settledQuestions), + specWithSteps("1. The parser\n - landed: #1\n"), CheckSteps, "landed"}, + {"an unreadable steps section", "itd-10", plannedRel, readyIntent("", settledQuestions), + specWithSteps("the parser, then the loop\n"), CheckSteps, "Steps"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + if tc.intentRel != plannedRel { + repo.Remove(plannedRel) + } + repo.Write(tc.intentRel, tc.intent) + repo.Write(specRel, tc.spec) + repo.Commit("fixture") + + _, err := Start(repo.Root(), tc.key, Options{}) + r := mustRefusal(t, err) + if r.Step != "check" || r.Check != tc.check { + t.Fatalf("want the %s check named, got step %q check %q: %v", tc.check, r.Step, r.Check, r) + } + if !strings.Contains(r.Reason, tc.reason) { + t.Fatalf("the reason must say why (%q): %q", tc.reason, r.Reason) + } + if r.Contention { + t.Fatalf("a failed check is the record's, not a peer's: %+v", r) + } + runTierAbsent(t, repo.Root()) + }) + } +} + +// TestStartRefusesAPeerHoldingTheRecord is criterion 2, from both sources a +// peer holds a record through: a branch holding the intent in another bucket +// (the peer listing), and a live claim another session took on it (the run's +// claim store). Each names the peer, is contention, and writes no state. +func TestStartRefusesAPeerHoldingTheRecord(t *testing.T) { + t.Run("a branch", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + repo.Git("checkout", "-q", "-b", "lane-alpha") + repo.Remove(plannedRel) + repo.Write(".abcd/development/intents/shipped/itd-10-alpha.md", readyIntent("", settledQuestions)) + repo.Commit("deliver alpha") + repo.Git("checkout", "-q", "main") + + _, err := Start(repo.Root(), "itd-10", Options{}) + r := mustRefusal(t, err) + if r.Check != CheckPeers || !r.Contention { + t.Fatalf("want the peers check as contention: %+v", r) + } + if !strings.Contains(r.Reason, "lane-alpha") || !strings.Contains(r.Reason, "shipped") { + t.Fatalf("the refusal names the peer and where it holds the record: %q", r.Reason) + } + runTierAbsent(t, repo.Root()) + }) + t.Run("a claim", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + sha := repo.Git("rev-list", "--max-parents=0", "HEAD") + run, err := implement.Open(strings.TrimSpace(sha)) + if err != nil { + t.Fatal(err) + } + if _, err := run.Join("peer-session", implement.RoleFirst, "", "", 0); err != nil { + t.Fatal(err) + } + if _, err := run.Claim(implement.ClaimRequest{Session: "peer-session", Record: "itd-10", Lane: "alpha"}); err != nil { + t.Fatal(err) + } + + _, err = Start(repo.Root(), "itd-10", Options{}) + r := mustRefusal(t, err) + if r.Check != CheckPeers || !r.Contention || !strings.Contains(r.Reason, "peer-session") { + t.Fatalf("want the peers check naming the claiming session: %+v", r) + } + runTierAbsent(t, repo.Root()) + }) +} + +// TestStartRefusesWithoutTheLocalTier: the tier is never created, so a +// repository abcd does not manage has no run. +func TestStartRefusesWithoutTheLocalTier(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + if err := os.RemoveAll(filepath.Join(repo.Root(), ".abcd", ".work.local")); err != nil { + t.Fatal(err) + } + _, err := Start(repo.Root(), "itd-10", Options{}) + r := mustRefusal(t, err) + if r.Step != "state" || !strings.Contains(r.Reason, TierRelDir) { + t.Fatalf("want the missing tier named: %+v", r) + } + if _, err := os.Lstat(filepath.Join(repo.Root(), ".abcd", ".work.local")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the tier must never be created: %v", err) + } +} + +// TestStartCreatesOneLaneAndAStartAgainResumesIt is criterion 3's state half +// and criterion 7's entry: the state file exists with one lane for the first +// unlanded spec step, the rest pending, and a second start resumes the same run +// rather than opening another. +func TestStartCreatesOneLaneAndAStartAgainResumesIt(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), + specWithSteps("1. The parser\n - landed: #1\n2. The loop\n - packages: internal/core/implement/loop\n3. The verb\n")) + res, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + if res.Resumed || !ValidRunID(res.RunID) || res.State != StateRelPath(res.RunID) { + t.Fatalf("StartResult = %+v", res) + } + st, err := ReadState(repo.Root(), res.RunID) + if err != nil { + t.Fatal(err) + } + if st.Key != "itd-10" || st.Intent != "itd-10" || st.Spec != "spc-1" || st.Driver != DriverHost { + t.Fatalf("state = %+v", st) + } + if len(st.Lanes) != 1 { + t.Fatalf("a run starts with one lane, got %d", len(st.Lanes)) + } + l := st.Lanes[0] + if l.ID != "lane-1" || l.Key != "itd-10" || l.SpecStep != 2 || l.StepTitle != "The loop" || l.Step != StepWorktree { + t.Fatalf("lane = %+v", l) + } + if len(st.Pending) != 1 || st.Pending[0].Number != 3 { + t.Fatalf("the unlanded steps after the first wait as pending: %+v", st.Pending) + } + if len(st.Record) != 1 || st.Record[0].Step != "start" { + t.Fatalf("the record opens with the start: %+v", st.Record) + } + fi, err := os.Stat(filepath.Join(repo.Root(), filepath.FromSlash(res.State))) + if err != nil || fi.Mode().Perm() != filePerm { + t.Fatalf("the state file is the caller's own: %v %v", fi, err) + } + + again, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + if !again.Resumed || again.RunID != res.RunID { + t.Fatalf("a second start resumes the run: %+v", again) + } + runs, err := Runs(repo.Root()) + if err != nil || len(runs) != 1 { + t.Fatalf("one run, not two: %d %v", len(runs), err) + } +} + +// TestAnUnsteppedSpecIsOneLane: a spec listing no steps is built as one step. +func TestAnUnsteppedSpecIsOneLane(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + res, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + st, _ := ReadState(repo.Root(), res.RunID) + if len(st.Lanes) != 1 || st.Lanes[0].SpecStep != 1 || st.Lanes[0].StepTitle != "the whole spec" || len(st.Pending) != 0 { + t.Fatalf("state = %+v", st) + } +} + +// fakeSteps is a lane sequence whose bodies a test controls: each counts its +// calls; the implement step hands work to an agent and verifies the receipt. +type fakeSteps struct { + calls map[StepName]int + failing StepName +} + +func (f *fakeSteps) steps() Steps { + body := func(name StepName, fill func(*Lane)) Handler { + return func(c Context, lane *Lane) (Outcome, error) { + f.calls[name]++ + if name == f.failing { + return Outcome{}, errors.New("killed mid-step") + } + if fill != nil { + fill(lane) + } + return Outcome{Note: string(name) + " done"}, nil + } + } + return Steps{ + {Name: StepWorktree, Piece: 6, Run: body(StepWorktree, func(l *Lane) { l.Branch = "build/" + l.ID })}, + {Name: StepBrief, Piece: 5, Run: body(StepBrief, func(l *Lane) { l.Brief = RunRelDir + "/brief.md" })}, + {Name: StepImplement, Piece: 7, + Run: func(c Context, lane *Lane) (Outcome, error) { + f.calls[StepImplement]++ + return Outcome{Await: &Await{Role: "implementer", Brief: lane.Brief, + Receipt: filepath.Join(c.RepoRoot, "receipt-"+lane.ID+".json")}}, nil + }, + Verify: func(c Context, lane *Lane, receipt string) error { + if _, err := os.Stat(receipt); err != nil { + return refuse("receipt", "", lane.ID, "the receipt names no report", "write the report, then hand the receipt back") + } + return nil + }}, + {Name: StepValidate, Piece: 8, Run: body(StepValidate, nil)}, + {Name: StepLand, Piece: 9, Run: body(StepLand, nil)}, + } +} + +func stateBytes(t *testing.T, root, runID string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(StateRelPath(runID)))) + if err != nil { + t.Fatal(err) + } + return b +} + +// TestTheHostDrivesTheLoopEndToEnd plays the host (the spec's Approach): step +// performs one binary-owned step per call, returns the agent, brief and +// receipt path at an agent step, advances only on the receipt, and opens the +// next spec step's lane when a lane is done, until the run is complete. +func TestTheHostDrivesTheLoopEndToEnd(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("1. The parser\n2. The loop\n")) + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + f := &fakeSteps{calls: map[StepName]int{}} + steps := f.steps() + id := start.RunID + + for lane := 1; lane <= 2; lane++ { + for _, want := range []StepName{StepWorktree, StepBrief} { + res, err := Advance(repo.Root(), id, steps, Options{}) + if err != nil { + t.Fatal(err) + } + if res.Performed != want { + t.Fatalf("lane %d: performed %q, want %q (%+v)", lane, res.Performed, want, res) + } + } + res, err := Advance(repo.Root(), id, steps, Options{}) + if err != nil { + t.Fatal(err) + } + if res.Awaiting == nil || res.Awaiting.Role != "implementer" || res.Awaiting.Receipt == "" || res.Awaiting.Brief == "" { + t.Fatalf("an agent step tells the host which agent, which brief and where the receipt goes: %+v", res) + } + if !strings.Contains(res.Next, "implement receipt") { + t.Fatalf("the next move names the receipt verb: %q", res.Next) + } + receipt := res.Awaiting.Receipt + + // Asking again tells the same thing and moves nothing. + before := stateBytes(t, repo.Root(), id) + again, err := Advance(repo.Root(), id, steps, Options{}) + if err != nil || again.Awaiting == nil || again.Awaiting.Receipt != receipt || again.Performed != "" { + t.Fatalf("a step while awaiting re-tells the await: %+v %v", again, err) + } + if !bytes.Equal(before, stateBytes(t, repo.Root(), id)) { + t.Fatal("a step while awaiting must not write the state") + } + + // The loop advances only on the receipt it named, and only once it verifies. + if _, err := Receipt(repo.Root(), id, filepath.Join(repo.Root(), "elsewhere.json"), steps, Options{}); err == nil { + t.Fatal("a receipt at another path must be refused") + } + _, err = Receipt(repo.Root(), id, receipt, steps, Options{}) + if r := mustRefusal(t, err); r.Step != "receipt" { + t.Fatalf("an unverified receipt is refused at the receipt: %+v", r) + } + if !bytes.Equal(before, stateBytes(t, repo.Root(), id)) { + t.Fatal("a refused receipt must not move the lane") + } + if err := os.WriteFile(receipt, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := Receipt(repo.Root(), id, receipt, steps, Options{}) + if err != nil { + t.Fatal(err) + } + if got.Performed != StepImplement || got.Step != StepValidate { + t.Fatalf("a verified receipt completes the agent step: %+v", got) + } + for _, want := range []StepName{StepValidate, StepLand} { + res, err := Advance(repo.Root(), id, steps, Options{}) + if err != nil || res.Performed != want { + t.Fatalf("lane %d: performed %q, want %q (%v)", lane, res.Performed, want, err) + } + } + } + st, err := ReadState(repo.Root(), id) + if err != nil { + t.Fatal(err) + } + if !st.Complete() || len(st.Lanes) != 2 || st.Lanes[1].SpecStep != 2 || st.Lanes[1].Branch != "build/lane-2" { + t.Fatalf("both spec steps landed through their own lanes: %+v", st) + } + fin, err := Advance(repo.Root(), id, steps, Options{}) + if err != nil || !fin.Complete { + t.Fatalf("a complete run says so: %+v %v", fin, err) + } + for name, n := range f.calls { + if n != 2 { + t.Fatalf("step %s ran %d times over two lanes, want 2", name, n) + } + } +} + +// TestAKilledStepRepeatsAndACompletedStepDoesNot is criterion 7: a step that +// did not complete leaves the state as it was, so the next invocation performs +// it again, and a step the state records as done is never performed twice. +func TestAKilledStepRepeatsAndACompletedStepDoesNot(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + f := &fakeSteps{calls: map[StepName]int{}, failing: StepBrief} + if _, err := Advance(repo.Root(), start.RunID, f.steps(), Options{}); err != nil { + t.Fatal(err) + } + before := stateBytes(t, repo.Root(), start.RunID) + if _, err := Advance(repo.Root(), start.RunID, f.steps(), Options{}); err == nil { + t.Fatal("a step that fails must report it") + } + if !bytes.Equal(before, stateBytes(t, repo.Root(), start.RunID)) { + t.Fatal("a step that did not complete must leave the state as it was") + } + f.failing = "" + res, err := Advance(repo.Root(), start.RunID, f.steps(), Options{}) + if err != nil || res.Performed != StepBrief { + t.Fatalf("the next invocation performs the step that did not complete: %+v %v", res, err) + } + if f.calls[StepWorktree] != 1 || f.calls[StepBrief] != 2 { + t.Fatalf("completed steps are not repeated: %v", f.calls) + } +} + +// TestAStepThisBuildDoesNotCarryIsRefusedByName: the production sequence names +// every step; one whose body is not built is refused with the piece that +// delivers it, and the run is unchanged. +func TestAStepThisBuildDoesNotCarryIsRefusedByName(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + before := stateBytes(t, repo.Root(), start.RunID) + _, err = Advance(repo.Root(), start.RunID, DefaultSteps(), Options{}) + r := mustRefusal(t, err) + if r.Step != string(StepWorktree) || r.Lane != "lane-1" || !strings.Contains(r.Reason, "piece 6") { + t.Fatalf("want the unbuilt step and its piece named: %+v", r) + } + if !bytes.Equal(before, stateBytes(t, repo.Root(), start.RunID)) { + t.Fatal("a refused step must leave the run unchanged") + } + if names := DefaultSteps(); len(names) != len(Sequence) { + t.Fatalf("the production sequence names every step: %d of %d", len(names), len(Sequence)) + } +} + +// TestAPauseRefusesUntilNextEligibleAt is decision 2's pause: before the +// window clock's next_eligible_at a step is refused as contention, naming the +// time, and nothing moves. +func TestAPauseRefusesUntilNextEligibleAt(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + start, err := Start(repo.Root(), "itd-10", Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + st, _ := ReadState(repo.Root(), start.RunID) + later := now.Add(time.Hour) + st.NextEligibleAt = &later + root, _ := os.OpenRoot(repo.Root()) + defer root.Close() + if err := writeState(root, st); err != nil { + t.Fatal(err) + } + f := &fakeSteps{calls: map[StepName]int{}} + _, err = Advance(repo.Root(), start.RunID, f.steps(), Options{Now: func() time.Time { return now }}) + r := mustRefusal(t, err) + if r.Step != "pause" || !r.Contention || !strings.Contains(r.Reason, "2026-09-25T13:00:00Z") { + t.Fatalf("want the pause named: %+v", r) + } + if f.calls[StepWorktree] != 0 { + t.Fatal("a paused run performs nothing") + } + res, err := Advance(repo.Root(), start.RunID, f.steps(), Options{Now: func() time.Time { return later }}) + if err != nil || res.Performed != StepWorktree { + t.Fatalf("at next_eligible_at the loop moves again: %+v %v", res, err) + } +} + +// TestReadStateFailsClosed: a run id of the wrong shape, an unknown field and +// another schema version are each refused rather than read. +func TestReadStateFailsClosed(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := ReadState(repo.Root(), "../../etc"); err == nil { + t.Fatal("a traversal is not a run id") + } + path := filepath.Join(repo.Root(), filepath.FromSlash(StateRelPath(start.RunID))) + good := stateBytes(t, repo.Root(), start.RunID) + for name, bad := range map[string]string{ + "unknown field": strings.Replace(string(good), `"schema_version": 1,`, `"schema_version": 1, "verdict": "SHIP",`, 1), + "schema version": strings.Replace(string(good), `"schema_version": 1,`, `"schema_version": 2,`, 1), + } { + if err := os.WriteFile(path, []byte(bad), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadState(repo.Root(), start.RunID); err == nil { + t.Fatalf("%s: the reader must refuse", name) + } + } +} + +// TestResolveNamesTheOnlyLiveRun: a call without a run id addresses the one +// live run, and is refused naming them when there are several or none. +func TestResolveNamesTheOnlyLiveRun(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + if _, err := Resolve(repo.Root(), ""); err == nil { + t.Fatal("no run: refused") + } + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + id, err := Resolve(repo.Root(), "") + if err != nil || id != start.RunID { + t.Fatalf("Resolve = %q %v, want %q", id, err, start.RunID) + } +} + +// TestRefusalRendersStepReasonAndRemedy is criterion 13's text half. +func TestRefusalRendersStepReasonAndRemedy(t *testing.T) { + r := &Refusal{Step: "check", Check: CheckHold, Reason: "itd-10 is held", Remedy: "run `abcd intent unhold itd-10`"} + if got := r.Error(); got != "refused at check (hold): itd-10 is held; remedy: run `abcd intent unhold itd-10`" { + t.Fatalf("Error() = %q", got) + } +} diff --git a/internal/core/implement/loop/refusal.go b/internal/core/implement/loop/refusal.go new file mode 100644 index 000000000..558f50634 --- /dev/null +++ b/internal/core/implement/loop/refusal.go @@ -0,0 +1,75 @@ +package loop + +import ( + "errors" + "fmt" + "strings" +) + +// Refusal is the one shape every refusal of the loop takes (criterion 13): the +// step it happened at, the reason and the remedy, in text and in --json. A +// refusal writes nothing: the state file is as it was before the call. +type Refusal struct { + // Step is where the loop refused: "check" before a run starts, "state" for + // a run that cannot be read, "pause" for the window clock, or the lane step + // ("worktree", "implement", …) and "receipt" inside a run. + Step string `json:"step"` + // Check names the pre-start check that failed, when Step is "check". + Check string `json:"check,omitempty"` + // Lane names the lane, inside a run. + Lane string `json:"lane,omitempty"` + Reason string `json:"reason"` + Remedy string `json:"remedy"` + // Contention marks a refusal that is somebody else's hold rather than the + // caller's fault — a peer holding the record, a run locked by another + // invocation, a pause: back off and take other work. The CLI maps it to + // exit 3, as `abcd implement` does. + Contention bool `json:"contention,omitempty"` + // Checks is every pre-start check's row, when Step is "check", so a caller + // sees the whole picture rather than the first failure. + Checks []CheckRow `json:"checks,omitempty"` +} + +// Error renders the refusal as one line: step, reason, remedy. +func (r *Refusal) Error() string { + var b strings.Builder + b.WriteString("refused at ") + b.WriteString(r.Step) + if r.Check != "" { + b.WriteString(" (" + r.Check + ")") + } + if r.Lane != "" { + b.WriteString(" for " + r.Lane) + } + b.WriteString(": " + r.Reason) + if r.Remedy != "" { + b.WriteString("; remedy: " + r.Remedy) + } + return b.String() +} + +// AsRefusal returns err's Refusal, if it carries one. +func AsRefusal(err error) (*Refusal, bool) { + var r *Refusal + if errors.As(err, &r) { + return r, true + } + return nil, false +} + +// refuse builds a Refusal. +func refuse(step, check, lane, reason, remedy string) *Refusal { + return &Refusal{Step: step, Check: check, Lane: lane, Reason: reason, Remedy: remedy} +} + +// contend builds a contention Refusal. +func contend(step, check, lane, reason, remedy string) *Refusal { + r := refuse(step, check, lane, reason, remedy) + r.Contention = true + return r +} + +// refusef is refuse with a formatted reason. +func refusef(step, lane, remedy, format string, a ...any) *Refusal { + return refuse(step, "", lane, fmt.Sprintf(format, a...), remedy) +} diff --git a/internal/core/implement/loop/state.go b/internal/core/implement/loop/state.go new file mode 100644 index 000000000..a8e59ff53 --- /dev/null +++ b/internal/core/implement/loop/state.go @@ -0,0 +1,317 @@ +// Package loop is the implement loop's engine (itd-2609201916151817, +// spc-2609202134338445): the state file a run lives in, the checks that decide +// whether a run may start, and the step interface a driving host calls. `abcd +// build ` starts a run; `abcd implement step` performs the next step and +// exits; `abcd implement receipt ` hands back what an agent step waited +// on; `abcd implement status` renders the state. Nothing here holds a run in +// memory between invocations: every call reads the state file first, does at +// most one step, writes the state file last and returns (decisions 1 and 2), so +// a killed process loses only the step it was in, which the next call repeats. +// +// The state lives in the checkout's local tier: +// +// .abcd/.work.local/run//state.json the run: its key, lanes, clock and record +// .abcd/.work.local/run/.lock the advisory lock every mutation takes +// +// The tier is never created here: only a repository abcd manages has it, which +// is what keeps a run managed-only without a check of its own to drift from +// (internal/core/mode draws the same line). The run directories beneath it are +// created one level at a time and proved real (fsutil.EnsureRealDirAll), and the +// state is written through the package's one atomic writer inside an os.Root, so +// a symlinked component is refused rather than followed. +// +// What this package does NOT do is the work of a step. The lane's steps are a +// sequence (Sequence), and each step's body is a Handler the piece of the spec +// that delivers it registers in DefaultSteps: the worktree (piece 6), the brief +// (piece 5), the receipt's verifier (piece 7), the validators (piece 8) and the +// landing (piece 9). A step whose body this build does not carry is refused by +// name, with the piece that delivers it, and the run is left unchanged. The +// process driver (piece 3, waiting on the runner intent itd-2609201916056194) +// is the same loop called by a process instead of a host: it starts the agent an +// Await names through the runner and then calls Receipt, so it needs no seam +// beyond the two this package exports. +// +// Core never writes to stdout; the CLI front door formats what these functions +// return. +package loop + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "regexp" + "slices" + "time" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// TierRelDir is the local-ephemeral tier the run state lives in. It is never +// created here. +const TierRelDir = ".abcd/.work.local" + +// RunRelDir is the directory every run of this checkout lives under. +const RunRelDir = TierRelDir + "/run" + +// StateFileName is the name of a run's state file inside its directory. +const StateFileName = "state.json" + +// lockFileName is the advisory lock every mutation of any run takes. +const lockFileName = ".lock" + +// SchemaVersion is the state file's shape. A file carrying any other version is +// refused rather than read as this one: a field a later build added and this +// one would drop on its next write is a run silently losing state. +const SchemaVersion = 1 + +// RunIDFamily is the run id's prefix; the id is minted through the record-id +// seam (adr-45), so two checkouts starting runs in one second draw distinct ids. +const RunIDFamily = "run" + +// dirPerm and filePerm are the modes the run state is created with: the run is +// the caller's own business. +const ( + dirPerm fs.FileMode = 0o700 + filePerm fs.FileMode = 0o600 +) + +// maxStateBytes caps a state file read. A run's record grows by one entry per +// step; a file past this is not one the loop wrote. +const maxStateBytes = 4 << 20 + +// lockTimeout bounds how long a mutation waits for another invocation's +// mutation to finish before it reports contention. +const lockTimeout = 3 * time.Second + +// runIDRe is the shape of a run id, checked before any path is built from one. +var runIDRe = regexp.MustCompile(`^run-[0-9]{16}$`) + +// ValidRunID reports whether id is a run id this package mints. +func ValidRunID(id string) bool { return runIDRe.MatchString(id) } + +// StepName is one step of a lane. +type StepName string + +// The lane's steps, in the order Sequence performs them. StepDone is the state +// of a lane with nothing left to do, never a step with a body. +const ( + StepWorktree StepName = "worktree" + StepBrief StepName = "brief" + StepImplement StepName = "implement" + StepValidate StepName = "validate" + StepLand StepName = "land" + StepDone StepName = "done" +) + +// Driver names what drives the loop. The host session is decision 5's default; +// the process driver is piece 3's, opt-in by configuration. +type Driver string + +// DriverHost is a host session calling step and receipt itself. +const DriverHost Driver = "host" + +// State is one run: everything the loop needs between two invocations. +type State struct { + SchemaVersion int `json:"schema_version"` + // RunID names the run and its directory. + RunID string `json:"run_id"` + // Key is the record the run was started for: an intent id (an issue id is + // decision 10's, which a later piece admits). + Key string `json:"key"` + // Intent and Spec are the intent the run delivers and the open spec it + // builds against, as the readiness gate judged them. + Intent string `json:"intent"` + Spec string `json:"spec"` + // Driver is what drives the loop (DriverHost unless configured). + Driver Driver `json:"driver"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // WindowStartedAt and NextEligibleAt are the window clock the pacing intent + // (itd-2609201925079472) writes and reads. The loop honours NextEligibleAt: + // before it, a step is refused as a pause and nothing moves (decision 2). + WindowStartedAt *time.Time `json:"window_started_at,omitempty"` + NextEligibleAt *time.Time `json:"next_eligible_at,omitempty"` + // Lanes are the lanes opened so far, one at a time, in order. A lane lands + // one step of the spec's `## Steps` (the whole spec when it lists none). + Lanes []Lane `json:"lanes"` + // Pending are the spec's unlanded steps no lane has been opened for yet. + Pending []PendingStep `json:"pending"` + // Record is the run record, accumulated as steps complete. + Record []Entry `json:"record"` +} + +// PendingStep is a spec step the run will open a lane for. +type PendingStep struct { + Number int `json:"number"` + Title string `json:"title"` +} + +// Lane is one lane: one spec step, one branch, one pull request. +type Lane struct { + // ID is the lane's name inside the run: lane-1, lane-2, …. + ID string `json:"id"` + // Key is the record the lane delivers (itd-N; iss-N once decision 10 lands). + Key string `json:"key"` + // SpecStep is the number of the spec step the lane lands, and StepTitle its + // title. An unstepped spec is one implicit step, number 1. + SpecStep int `json:"spec_step"` + StepTitle string `json:"step_title"` + // Step is the next step the loop performs for this lane; StepDone when the + // lane has nothing left. + Step StepName `json:"step"` + // Awaiting is set while the lane waits on an agent: the step handed its + // work out and advances only on the receipt it names (criterion 8). + Awaiting *Await `json:"awaiting,omitempty"` + // The lane's footprint, filled by the steps that make it. + Branch string `json:"branch,omitempty"` + BaseSHA string `json:"base_sha,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + Worktree string `json:"worktree,omitempty"` + Brief string `json:"brief,omitempty"` + Receipt string `json:"receipt,omitempty"` + PR int `json:"pr,omitempty"` +} + +// Await is what a lane waits on: the agent a host must start, the brief it is +// handed and the receipt it writes. +type Await struct { + // Role is the agent the host starts: an implementer, or a validator that + // did not implement. + Role string `json:"role"` + // Brief is the file the agent is handed. + Brief string `json:"brief"` + // Receipt is where the agent writes its receipt; `implement receipt` is + // called with this path. + Receipt string `json:"receipt"` + Since time.Time `json:"since"` +} + +// Entry is one line of the run record. +type Entry struct { + At time.Time `json:"at"` + Lane string `json:"lane,omitempty"` + // Step is the step the entry records: a lane step, "start" or "receipt". + Step string `json:"step"` + Note string `json:"note,omitempty"` +} + +// Complete reports whether the run has nothing left: every lane is done and no +// spec step is waiting for one. +func (s State) Complete() bool { + if len(s.Pending) > 0 { + return false + } + for _, l := range s.Lanes { + if l.Step != StepDone { + return false + } + } + return true +} + +// current returns the index of the lane the loop works on — the first lane not +// done — or -1 when every opened lane is done. +func (s State) current() int { + for i, l := range s.Lanes { + if l.Step != StepDone { + return i + } + } + return -1 +} + +// runRel is a run's directory, relative to the checkout root. +func runRel(runID string) string { return RunRelDir + "/" + runID } + +// StateRelPath is a run's state file, relative to the checkout root. +func StateRelPath(runID string) string { return runRel(runID) + "/" + StateFileName } + +// ReadState is the state file's one reader. It refuses a run id of the wrong +// shape before building a path from it, reads through the checkout's os.Root +// (so a symlinked component cannot walk the read out of the checkout), decodes +// strictly — an unknown field is a file some other writer produced — and holds +// the file to the version and the id it is stored under. +func ReadState(repoRoot, runID string) (State, error) { + if !ValidRunID(runID) { + return State{}, refuse("state", "", "", fmt.Sprintf("%q is not a run id (run-<16 digits>)", runID), + "name a run `abcd implement status` lists") + } + root, err := os.OpenRoot(repoRoot) + if err != nil { + return State{}, fmt.Errorf("opening the checkout to read the run state: %w", err) + } + defer root.Close() + return readStateIn(root, runID) +} + +func readStateIn(root *os.Root, runID string) (State, error) { + rel := StateRelPath(runID) + data, err := fsutil.ReadGuardedInRoot(root, rel, maxStateBytes) + if errors.Is(err, fs.ErrNotExist) { + return State{}, refuse("state", "", "", "no run "+runID+" in this checkout", + "name a run `abcd implement status` lists, or start one with `abcd build `") + } + if err != nil { + return State{}, fmt.Errorf("reading %s: %w", rel, err) + } + var st State + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&st); err != nil { + return State{}, refuse("state", "", "", fmt.Sprintf("%s does not parse as a run state: %v", rel, err), + "the loop is the file's only writer; restore it or remove the run directory "+runRel(runID)) + } + if st.SchemaVersion != SchemaVersion { + return State{}, refuse("state", "", "", fmt.Sprintf("%s is schema version %d; this abcd reads version %d", rel, st.SchemaVersion, SchemaVersion), + "run the abcd that wrote it") + } + if st.RunID != runID { + return State{}, refuse("state", "", "", fmt.Sprintf("%s names run %q, not the run it is stored under", rel, st.RunID), + "the loop is the file's only writer; restore it or remove the run directory "+runRel(runID)) + } + return st, nil +} + +// writeState is the state file's one writer: a whole-file atomic replacement +// inside the checkout's os.Root, so a reader sees the old state or the new one, +// never half of either. The caller holds the lock. +func writeState(root *os.Root, st State) error { + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + rel := StateRelPath(st.RunID) + if err := fsutil.WriteFileAtomicInRoot(root, rel, data, filePerm); err != nil { + return fmt.Errorf("writing %s: %w", rel, err) + } + return nil +} + +// runIDs lists the run directories under the run tier, in name order (which is +// mint order). An absent tier holds none. +func runIDs(root *os.Root) ([]string, error) { + f, err := root.Open(RunRelDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("listing %s: %w", RunRelDir, err) + } + defer f.Close() + names, err := f.Readdirnames(-1) + if err != nil { + return nil, fmt.Errorf("listing %s: %w", RunRelDir, err) + } + var ids []string + for _, n := range names { + if ValidRunID(n) { + ids = append(ids, n) + } + } + slices.Sort(ids) + return ids, nil +} diff --git a/internal/core/intent/questions.go b/internal/core/intent/questions.go new file mode 100644 index 000000000..fbd0ed13c --- /dev/null +++ b/internal/core/intent/questions.go @@ -0,0 +1,45 @@ +package intent + +import ( + "regexp" + "strings" + + "github.com/intentdriven/abcd/internal/core/mdrecord" +) + +// questions.go reads what an intent's `## Open Questions` section still asks — +// the count the build's pre-start check refuses on (itd-2609201916151817, +// criterion 1: a READY intent with an open question does not start). + +var ( + // openQuestionsHeadingRe matches the `## Open Questions` heading (any depth). + openQuestionsHeadingRe = regexp.MustCompile(`^#{1,6}\s+Open Questions\s*$`) + // numberedItemRe is a column-0 numbered list item, `1. text` or `1) text` — + // the other spelling of a list a question is written in. + numberedItemRe = regexp.MustCompile(`^[0-9]{1,4}[.)][ \t]+(\S.*)$`) +) + +// OpenQuestions returns the questions an intent's `## Open Questions` section +// still asks: one per top-level list item, bulleted or numbered, in document +// order, each as its first line's text. A settled record says so in prose — the +// minted `_None recorded yet._`, or `_None open; …_` naming the decisions that +// answered them — and prose, a blockquote and an indented continuation are not +// questions. A record with no such section asks none. +// +// It reads fail-closed rather than clever: an item is a question whatever it +// says, so a record that keeps its answered questions as list items under this +// heading reads as asking them. The remedy is the record's own convention — +// the answer moves to `## Decisions` and the section says it is settled. +func OpenQuestions(content string) []string { + var out []string + for _, ln := range strings.Split(sectionBody(content, openQuestionsHeadingRe), "\n") { + ln = strings.TrimRight(ln, "\r") + switch { + case mdrecord.IsTopLevelBullet(ln): + out = append(out, strings.TrimSpace(mdrecord.TrimBulletPrefix(ln))) + case numberedItemRe.MatchString(ln): + out = append(out, strings.TrimSpace(numberedItemRe.FindStringSubmatch(ln)[1])) + } + } + return out +} diff --git a/internal/core/intent/questions_test.go b/internal/core/intent/questions_test.go new file mode 100644 index 000000000..47f33a14f --- /dev/null +++ b/internal/core/intent/questions_test.go @@ -0,0 +1,37 @@ +package intent + +import ( + "reflect" + "testing" +) + +// TestOpenQuestionsCountsListItemsAndNothingElse pins what the build's +// open-question check reads (itd-2609201916151817, criterion 1): a top-level +// list item under `## Open Questions` is a question still asked; the italic +// "none open" line every settled record carries, prose, a blockquote, an +// indented continuation and a list under another heading are not. +func TestOpenQuestionsCountsListItemsAndNothingElse(t *testing.T) { + t.Parallel() + cases := []struct { + name string + content string + want []string + }{ + {"no section", "# t\n\n## Decisions\n\n- a ruling\n", nil}, + {"settled", "# t\n\n## Open Questions\n\n_None open; decisions 1 to 3 settle them._\n\n## Audit Notes\n\n- note\n", nil}, + {"prose only", "## Open Questions\n\nNone beyond the flagged decision above.\n", nil}, + {"blockquote", "## Open Questions\n\n> - asked at the interview and answered there\n", nil}, + {"bullets", "## Open Questions\n\n- **Where does it live?** Either here\n or there.\n* Who reads it?\n\n## Acceptance Criteria\n\n- Given x\n", + []string{"**Where does it live?** Either here", "Who reads it?"}}, + {"numbered", "## Open Questions\n\n1. Which runner?\n2) Which model?\n", []string{"Which runner?", "Which model?"}}, + {"crlf", "## Open Questions\r\n\r\n- Which one?\r\n", []string{"Which one?"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := OpenQuestions(tc.content); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("OpenQuestions = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go new file mode 100644 index 000000000..1a263bd27 --- /dev/null +++ b/internal/surface/cli/build.go @@ -0,0 +1,316 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/implement/loop" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" + "github.com/intentdriven/abcd/internal/termsafe" + "github.com/spf13/cobra" +) + +// build.go is the front door onto internal/core/implement/loop +// (itd-2609201916151817, spc-2609202134338445): `abcd build `, the verb a +// person types, and the step interface a driving host calls under `implement` +// (decision 8: `build` for people, `implement` for the machinery) — `implement +// status`, `implement step` and `implement receipt`. + +// loopStore is the noun the checkout resolution names in its refusal. +const loopStore = "the run state file" + +// loopRoot resolves the checkout the caller stands in. +func loopRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return gitutil.CheckoutRoot(cwd, loopStore) +} + +// loopRefusalDoc is the --json document a refusal renders before the error +// envelope: the step, the reason and the remedy as fields (criterion 13), and +// every pre-start check's row when the refusal is a check's. +type loopRefusalDoc struct { + Refusal loop.Refusal `json:"refusal"` +} + +// loopFail maps an error from the loop to the process outcome. A refusal exits +// 2, and contention (a peer holds the record, the run is paused or locked) exits +// 3: back off and take other work, as `abcd implement` does. Under --json the +// refusal is rendered as its own document first, so a machine reads the step, +// the reason and the remedy as fields; the envelope follows it, last on the +// stream as every refusal is. +func loopFail(w io.Writer, asJSON bool, prefix string, err error) error { + if r, ok := loop.AsRefusal(err); ok { + red := *r + red.Reason = fsutil.RedactHome(red.Reason) + red.Remedy = fsutil.RedactHome(red.Remedy) + code := 2 + if red.Contention { + code = 3 + } + if asJSON { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if encErr := enc.Encode(loopRefusalDoc{Refusal: red}); encErr != nil { + return encErr + } + } + return &exitError{Code: code, Msg: prefix + ": " + termsafe.Sanitize(red.Error()) + " (nothing written)"} + } + if errorsIsNoCheckout(err) { + return &exitError{Code: 2, Msg: prefix + ": " + err.Error() + " (nothing written)"} + } + return fmt.Errorf("%s: %w", prefix, err) +} + +// errorsIsNoCheckout reports the checkout-root refusal. +func errorsIsNoCheckout(err error) bool { return errors.Is(err, gitutil.ErrNoCheckoutRoot) } + +// newBuildCommand builds `abcd build `. +func newBuildCommand(asJSON *bool) *cobra.Command { + return &cobra.Command{ + Use: "build ", + Short: "Start a run that takes one READY intent to delivered, refusing while a decision is open", + Long: "Start the implement loop for one intent. The checks run first, and every one must pass:\n" + + "the intent is READY (planned, criteria written, its spec linked and written), asks no\n" + + "open question, has no unanswered claim section, is not held, its spec leaves a step to\n" + + "build, and no peer holds it (no sibling worktree or local branch holds it in another\n" + + "bucket, and no session holds a live claim on it). A refusal names the check, the reason\n" + + "and the remedy, and writes nothing.\n\n" + + "When the checks pass, the run is created in this checkout's local tier,\n" + + "`.abcd/.work.local/run//state.json`: one lane for the spec's first unlanded step,\n" + + "the other unlanded steps pending, and the run record's first line. The tier itself is\n" + + "never created: only a repository abcd manages has one. Starting again while the run is\n" + + "in progress creates nothing and names the run, so a killed process resumes where it\n" + + "stopped.\n\n" + + "The run then moves one step per `abcd implement step`, driven by the host session.\n\n" + + "Exit 2 on a refusal, exit 3 when a peer holds the intent or the run state is locked\n" + + "(back off and take other work).", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + const prefix = "abcd build" + root, err := loopRoot() + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + res, err := loop.Start(root, args[0], loop.Options{}) + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + verb := "started" + if res.Resumed { + verb = "resumed" + } + fmt.Fprintf(w, "build %s: %s run %s\n", termsafe.Sanitize(args[0]), verb, res.RunID) + fmt.Fprintf(w, " state: %s\n", res.State) + renderLaneLine(w, res.Lane) + renderPending(w, res.Pending) + fmt.Fprintf(w, "next: %s\n", termsafe.Sanitize(fsutil.RedactHome(res.Next))) + }) + }, + } +} + +// renderLaneLine renders one lane as a line. +func renderLaneLine(w io.Writer, l loop.Lane) { + fmt.Fprintf(w, " %s: spec step %d, %q — next: %s\n", l.ID, l.SpecStep, termsafe.Sanitize(l.StepTitle), l.Step) + if l.Awaiting != nil { + fmt.Fprintf(w, " awaiting the %s's receipt at %s (brief %s)\n", termsafe.Sanitize(l.Awaiting.Role), + termsafe.Sanitize(fsutil.RedactHome(l.Awaiting.Receipt)), termsafe.Sanitize(fsutil.RedactHome(l.Awaiting.Brief))) + } +} + +// renderPending renders the spec steps waiting for a lane. +func renderPending(w io.Writer, pending []loop.PendingStep) { + if len(pending) == 0 { + return + } + parts := make([]string, 0, len(pending)) + for _, p := range pending { + parts = append(parts, fmt.Sprintf("%d %q", p.Number, termsafe.Sanitize(p.Title))) + } + fmt.Fprintf(w, " pending: spec step %s\n", strings.Join(parts, ", ")) +} + +// redactAwait home-redacts the paths an await carries, for a stream. +func redactAwait(a *loop.Await) *loop.Await { + if a == nil { + return nil + } + c := *a + c.Brief = fsutil.RedactHome(c.Brief) + c.Receipt = fsutil.RedactHome(c.Receipt) + return &c +} + +// implementStatusRuns is `implement status`'s --json shape. +type implementStatusRuns struct { + Runs []loop.State `json:"runs"` +} + +func newImplementStatusCommand(asJSON *bool) *cobra.Command { + var runID string + cmd := &cobra.Command{ + Use: "status [--run ]", + Short: "Render the implement loop's runs in this checkout: lanes, steps, what each awaits (read-only)", + Long: "Render the runs `abcd build` started in this checkout, or the one --run names: the\n" + + "intent and spec, each lane with its spec step and next step, what an awaiting lane\n" + + "waits on, the pending spec steps, and the run record. Read-only: it writes nothing\n" + + "and creates nothing. Exit 2 when --run names no run.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + const prefix = "abcd implement status" + root, err := loopRoot() + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + var runs []loop.State + if runID != "" { + st, err := loop.ReadState(root, runID) + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + runs = []loop.State{st} + } else if runs, err = loop.Runs(root); err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + for i := range runs { + for j := range runs[i].Lanes { + runs[i].Lanes[j].Awaiting = redactAwait(runs[i].Lanes[j].Awaiting) + runs[i].Lanes[j].Worktree = fsutil.RedactHome(runs[i].Lanes[j].Worktree) + } + } + return render(cmd.OutOrStdout(), *asJSON, implementStatusRuns{Runs: runs}, func(w io.Writer) { + if len(runs) == 0 { + fmt.Fprintln(w, "no run in this checkout — start one with `abcd build `") + return + } + for _, st := range runs { + state := "in progress" + if st.Complete() { + state = "complete" + } + fmt.Fprintf(w, "run %s %s (%s) %s, driven by the %s\n", st.RunID, st.Key, st.Spec, state, st.Driver) + fmt.Fprintf(w, " state: %s\n", loop.StateRelPath(st.RunID)) + if st.NextEligibleAt != nil { + fmt.Fprintf(w, " paused until %s\n", st.NextEligibleAt.UTC().Format("2006-01-02T15:04:05Z07:00")) + } + for _, l := range st.Lanes { + renderLaneLine(w, l) + } + renderPending(w, st.Pending) + fmt.Fprintf(w, " record: %d line(s)\n", len(st.Record)) + for _, e := range st.Record { + fmt.Fprintf(w, " %s %-9s %s %s\n", e.At.Format("2006-01-02T15:04:05Z"), termsafe.Sanitize(e.Step), + termsafe.Sanitize(e.Lane), termsafe.Sanitize(fsutil.RedactHome(e.Note))) + } + } + }) + }, + } + cmd.Flags().StringVar(&runID, "run", "", "the run to render (run-<16 digits>); every run in this checkout when omitted") + return cmd +} + +// renderStepResult is the text form of a step or receipt result. +func renderStepResult(w io.Writer, verb string, res loop.StepResult) { + switch { + case res.Performed != "": + fmt.Fprintf(w, "%s: %s completed %s's %s step\n", verb, res.RunID, res.Lane, res.Performed) + case res.Awaiting != nil: + fmt.Fprintf(w, "%s: %s's %s step awaits the %s's receipt\n", verb, res.Lane, res.Step, termsafe.Sanitize(res.Awaiting.Role)) + fmt.Fprintf(w, " brief: %s\n receipt: %s\n", termsafe.Sanitize(res.Awaiting.Brief), termsafe.Sanitize(res.Awaiting.Receipt)) + case res.Complete: + fmt.Fprintf(w, "%s: %s is complete\n", verb, res.RunID) + } + fmt.Fprintf(w, "next: %s\n", termsafe.Sanitize(fsutil.RedactHome(res.Next))) +} + +func newImplementStepCommand(asJSON *bool) *cobra.Command { + var runID string + cmd := &cobra.Command{ + Use: "step [--run ]", + Short: "Perform the next step of an implement loop run and exit; at an agent step, name the agent, the brief and the receipt path", + Long: "Perform one step of the run's current lane, write the state, and exit. At a step that\n" + + "hands work to an agent, the result names the agent to start, the brief it is handed\n" + + "and the path its receipt goes to; the lane then advances only on\n" + + "`abcd implement receipt`, and asking for a step again re-tells the same thing and\n" + + "moves nothing. When a lane is done the spec's next pending step opens the next lane.\n" + + "A complete run says so.\n\n" + + "A step whose body this abcd does not carry is refused naming the spec piece that\n" + + "delivers it, and the run is unchanged. A step that fails leaves the state as it was,\n" + + "so the next invocation performs it again; a completed step is never repeated. Before\n" + + "the run's next_eligible_at the step is refused as a pause.\n\n" + + "--run names the run; without it, the one run in progress in this checkout. Exit 2 on a\n" + + "refusal, exit 3 on a pause or a locked run state.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + const prefix = "abcd implement step" + root, err := loopRoot() + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + id, err := loop.Resolve(root, runID) + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + res, err := loop.Advance(root, id, loop.DefaultSteps(), loop.Options{}) + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + res.Awaiting = redactAwait(res.Awaiting) + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { renderStepResult(w, "step", res) }) + }, + } + cmd.Flags().StringVar(&runID, "run", "", "the run to step (run-<16 digits>); the one run in progress when omitted") + return cmd +} + +func newImplementReceiptCommand(asJSON *bool) *cobra.Command { + var runID string + cmd := &cobra.Command{ + Use: "receipt [--run ]", + Short: "Hand back the receipt an agent step of an implement loop run awaits; the step completes only if it verifies", + Long: "Hand back the receipt the run's awaiting lane named when its step handed work to an\n" + + "agent. The path must be the one the step named. The step's verifier checks it; a\n" + + "receipt that verifies completes the step and the lane moves to its next step, and one\n" + + "that does not is refused naming what is missing, with the lane left where it was. A\n" + + "step whose verifier this abcd does not carry is refused naming the spec piece that\n" + + "delivers it.\n\n" + + "--run names the run; without it, the one run in progress in this checkout. Exit 2 on a\n" + + "refusal, exit 3 on a locked run state.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + const prefix = "abcd implement receipt" + root, err := loopRoot() + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + id, err := loop.Resolve(root, runID) + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + path := args[0] + if cwd, err := os.Getwd(); err == nil && !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + res, err := loop.Receipt(root, id, path, loop.DefaultSteps(), loop.Options{}) + if err != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + } + res.Awaiting = redactAwait(res.Awaiting) + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { renderStepResult(w, "receipt", res) }) + }, + } + cmd.Flags().StringVar(&runID, "run", "", "the run the receipt belongs to (run-<16 digits>); the one run in progress when omitted") + return cmd +} diff --git a/internal/surface/cli/build_surface_test.go b/internal/surface/cli/build_surface_test.go new file mode 100644 index 000000000..6a0eb6552 --- /dev/null +++ b/internal/surface/cli/build_surface_test.go @@ -0,0 +1,232 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +const ( + buildPlanned = ".abcd/development/intents/planned/itd-10-alpha.md" + buildSpec = ".abcd/development/specs/open/spc-1-alpha.md" +) + +// buildIntent is a planned intent every pre-start check passes. +func buildIntent() string { + return "---\nid: itd-10\nslug: alpha\nspec_id: spc-1\nkind: standalone\n---\n# alpha\n\n" + + "## Scope Conditions\n\nNone stated.\n\n## Acceptance Criteria\n\n- Given x, when y, then z.\n\n" + + "## Open Questions\n\n_None open._\n" + cliGroundsSection +} + +// buildRepo stands up a committed repository holding a READY intent and its +// written spec, with the local tier a run lives in, under a temporary HOME, and +// changes into it. +func buildRepo(t *testing.T) *gittest.Repo { + t.Helper() + t.Setenv("HOME", t.TempDir()) + repo := gittest.NewRepo(t) + repo.Write(".gitignore", ".abcd/.work.local/\n") + repo.Write(buildPlanned, buildIntent()) + repo.Write(buildSpec, "---\nid: spc-1\nslug: alpha\nintent: itd-10\n---\n# alpha\n\n## Summary\n\nA written design record.\n\n"+ + "## Steps\n\n1. The state file\n2. The verb\n") + repo.Commit("init") + if err := os.MkdirAll(filepath.Join(repo.Root(), ".abcd", ".work.local"), 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(repo.Root()) + return repo +} + +// jsonStream decodes every JSON document on a stream. +func jsonStream(t *testing.T, out string) []map[string]any { + t.Helper() + dec := json.NewDecoder(strings.NewReader(out)) + var docs []map[string]any + for { + var d map[string]any + if err := dec.Decode(&d); errors.Is(err, io.EOF) { + return docs + } else if err != nil { + t.Fatalf("stdout is not a stream of JSON documents (%v): %q", err, out) + } + docs = append(docs, d) + } +} + +// refusalDocs asserts a --json refusal: the refusal document with its step, +// reason and remedy as fields, then the error envelope, and nothing on stderr. +func refusalDocs(t *testing.T, want int, args ...string) map[string]any { + t.Helper() + code, out, errOut := implementCLI(t, args...) + if code != want { + t.Fatalf("abcd %s exited %d, want %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, want, out, errOut) + } + if strings.TrimSpace(errOut) != "" { + t.Fatalf("a --json refusal wrote prose to stderr: %q", errOut) + } + docs := jsonStream(t, out) + if len(docs) != 2 || docs[1]["abcd"] != "error" { + t.Fatalf("want the refusal document then the envelope, got %d document(s): %s", len(docs), out) + } + ref, ok := docs[0]["refusal"].(map[string]any) + if !ok { + t.Fatalf("the first document carries no refusal: %s", out) + } + for _, k := range []string{"step", "reason", "remedy"} { + if s, _ := ref[k].(string); s == "" { + t.Fatalf("the refusal names its %s as a field: %s", k, out) + } + } + return ref +} + +func runDirAbsent(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(root, ".abcd", ".work.local", "run")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a refusal must write no state (%v)", err) + } +} + +// TestBuildStartsARunThatStatusRendersAndABuildAgainResumes: `abcd build` +// creates the state file with one lane, `implement status` renders it, and a +// second build names the same run. +func TestBuildStartsARunThatStatusRendersAndABuildAgainResumes(t *testing.T) { + repo := buildRepo(t) + out := mustImplement(t, "build", "itd-10", "--json") + var res struct { + RunID string `json:"run_id"` + State string `json:"state"` + Resumed bool `json:"resumed"` + Lane struct { + ID string `json:"id"` + SpecStep int `json:"spec_step"` + Step string `json:"step"` + } `json:"lane"` + Pending []struct { + Number int `json:"number"` + } `json:"pending"` + Next string `json:"next"` + } + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("%v: %s", err, out) + } + if res.Resumed || res.Lane.ID != "lane-1" || res.Lane.SpecStep != 1 || res.Lane.Step != "worktree" || len(res.Pending) != 1 { + t.Fatalf("build result = %+v", res) + } + if !strings.Contains(res.Next, "abcd implement step") { + t.Fatalf("the next move names the step verb: %q", res.Next) + } + if _, err := os.Stat(filepath.Join(repo.Root(), filepath.FromSlash(res.State))); err != nil { + t.Fatalf("the state file exists: %v", err) + } + + text := mustImplement(t, "implement", "status") + for _, want := range []string{res.RunID, "itd-10", "lane-1", "The state file", "pending", "The verb"} { + if !strings.Contains(text, want) { + t.Fatalf("status must render %q:\n%s", want, text) + } + } + var st struct { + Runs []struct { + RunID string `json:"run_id"` + } `json:"runs"` + } + if err := json.Unmarshal([]byte(mustImplement(t, "implement", "status", "--json")), &st); err != nil || len(st.Runs) != 1 { + t.Fatalf("status --json lists the one run: %+v %v", st, err) + } + + again := mustImplement(t, "build", "itd-10") + if !strings.Contains(again, "resumed run "+res.RunID) { + t.Fatalf("a second build resumes the run:\n%s", again) + } +} + +// TestBuildRefusalNamesStepReasonAndRemedy is criterion 13 at the surface: a +// refused build names the step, the check, the reason and the remedy in text +// and in --json, exits 2, and writes nothing. +func TestBuildRefusalNamesStepReasonAndRemedy(t *testing.T) { + repo := buildRepo(t) + repo.Write(buildPlanned, strings.Replace(buildIntent(), "kind: standalone\n", "kind: standalone\nheld: \"awaiting the pacing ruling\"\n", 1)) + repo.Commit("hold") + + ref := refusalDocs(t, 2, "build", "itd-10", "--json") + if ref["step"] != "check" || ref["check"] != "hold" || !strings.Contains(ref["reason"].(string), "awaiting the pacing ruling") { + t.Fatalf("refusal = %v", ref) + } + if checks, _ := ref["checks"].([]any); len(checks) != 7 { + t.Fatalf("the refusal carries every check's row, got %d", len(checks)) + } + code, _, errOut := implementCLI(t, "build", "itd-10") + if code != 2 || !strings.Contains(errOut, "refused at check (hold)") || !strings.Contains(errOut, "remedy: settle the hold") { + t.Fatalf("text refusal: exit %d\n%s", code, errOut) + } + runDirAbsent(t, repo.Root()) +} + +// TestBuildRefusesAPeerHoldingTheIntentAtExit3 is criterion 2 at the surface: +// contention, so exit 3, naming the peer. +func TestBuildRefusesAPeerHoldingTheIntentAtExit3(t *testing.T) { + repo := buildRepo(t) + repo.Git("checkout", "-q", "-b", "lane-alpha") + repo.Remove(buildPlanned) + repo.Write(".abcd/development/intents/shipped/itd-10-alpha.md", buildIntent()) + repo.Commit("deliver alpha") + repo.Git("checkout", "-q", "main") + + ref := refusalDocs(t, 3, "build", "itd-10", "--json") + if ref["check"] != "peers" || !strings.Contains(ref["reason"].(string), "lane-alpha") { + t.Fatalf("refusal = %v", ref) + } + runDirAbsent(t, repo.Root()) +} + +// TestImplementStepRefusesAStepThisBuildDoesNotCarry: the production sequence +// carries no step body yet, so the first step is refused naming the piece that +// delivers it, and the state is unchanged. +func TestImplementStepRefusesAStepThisBuildDoesNotCarry(t *testing.T) { + repo := buildRepo(t) + var res struct { + State string `json:"state"` + } + if err := json.Unmarshal([]byte(mustImplement(t, "build", "itd-10", "--json")), &res); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(repo.Root(), filepath.FromSlash(res.State)) + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + ref := refusalDocs(t, 2, "implement", "step", "--json") + if ref["step"] != "worktree" || ref["lane"] != "lane-1" || !strings.Contains(ref["reason"].(string), "piece 6") { + t.Fatalf("refusal = %v", ref) + } + after, _ := os.ReadFile(statePath) + if !bytes.Equal(before, after) { + t.Fatal("a refused step must leave the state unchanged") + } + + ref = refusalDocs(t, 2, "implement", "receipt", "receipt.json", "--json") + if ref["step"] != "receipt" || !strings.Contains(ref["reason"].(string), "awaits a receipt") { + t.Fatalf("a receipt nothing awaits is refused: %v", ref) + } +} + +// TestImplementStepWithoutARunIsRefused: no run in progress, nothing to step. +func TestImplementStepWithoutARunIsRefused(t *testing.T) { + repo := buildRepo(t) + ref := refusalDocs(t, 2, "implement", "step", "--json") + if ref["step"] != "state" || !strings.Contains(ref["remedy"].(string), "abcd build") { + t.Fatalf("refusal = %v", ref) + } + if out := mustImplement(t, "implement", "status"); !strings.Contains(out, "no run in this checkout") { + t.Fatalf("status of no run says so:\n%s", out) + } + runDirAbsent(t, repo.Root()) +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index ae5cc9bd4..2f5aac697 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -277,6 +277,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newUpdateCommand(&asJSON)) root.AddCommand(newModeCommand(&asJSON)) root.AddCommand(newPeersCommand(&asJSON)) + root.AddCommand(newBuildCommand(&asJSON)) root.AddCommand(newImplementCommand(&asJSON)) root.AddCommand(newReportCommand(&asJSON)) root.AddCommand(newInboxCommand(&asJSON)) diff --git a/internal/surface/cli/implement.go b/internal/surface/cli/implement.go index 4f38df779..47060e96f 100644 --- a/internal/surface/cli/implement.go +++ b/internal/surface/cli/implement.go @@ -31,8 +31,8 @@ const implementStore = "the run state" func newImplementCommand(asJSON *bool) *cobra.Command { cmd := &cobra.Command{ Use: "implement", - Short: "Share one autonomous run between sessions: join, claim a record, check the bounds, log, and compare the division modes", - Long: "The run machinery an autonomous run calls. Every piece lives in the machine-scoped run\n" + + Short: "Share one autonomous run between sessions (join, claim, check, log, compare the modes) and drive the implement loop", + Long: "The run machinery an autonomous run calls. The shared run lives in the machine-scoped run\n" + "state, `~/.abcd/runs//`, keyed on the repository's root commit, so sessions\n" + "in different worktrees of one repository share one run and no repository file.\n\n" + "Bare `abcd implement` is read-only: the sessions that have joined, the claims and\n" + @@ -46,6 +46,10 @@ func newImplementCommand(asJSON *bool) *cobra.Command { "that touches the reading corpus, no lane in a split-roles window (`check` asks before\n" + "a step that is not a claim). `log` appends the run's other events, and `report`\n" + "derives the comparison of the modes from the log.\n\n" + + "`status`, `step` and `receipt` drive the implement loop `abcd build` starts, whose state\n" + + "lives in this checkout's local tier: `step` performs one step and exits, naming the\n" + + "agent, brief and receipt path when a step hands work to an agent, and `receipt`\n" + + "completes that step once the receipt verifies.\n\n" + "Exit 2 on a refusal (an unrecognised input, a session that has not joined, a bound\n" + "the session's role does not permit), exit 3 on contention (the record is claimed by\n" + "another session, or the run state is locked): back off and take other work.", @@ -102,6 +106,9 @@ func newImplementCommand(asJSON *bool) *cobra.Command { newImplementLogCommand(asJSON), newImplementReportCommand(asJSON), newImplementLoadCommand(asJSON), + newImplementStatusCommand(asJSON), + newImplementStepCommand(asJSON), + newImplementReceiptCommand(asJSON), ) return cmd } From 5f3a9eb63f153f3cd15bc4272270a4fbf07f1167 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:58:05 +0100 Subject: [PATCH 002/107] fix(rules): the DOGFOODING recall names every top-level verb AGENTS.md says the DOGFOODING domain injects the go-run rule on a prompt naming abcd or any of its top-level verbs, the Available Commands list of `abcd --help`. The recall list is hand-kept and had drifted: decide, implement, inbox, mode, peers, report and statusline were missing, and build, added in this branch, would have joined them. A prompt naming one of them never received the rule that keeps a session off the stale plugin-root binary. The list now names every verb, and a test in internal/surface/cli reads the list against the root command's available verbs, so the next verb added without joining it fails `go test` rather than going unnoticed. The capture travels with the fix. Refs: iss-2609251357387577 Assisted-by: Claude:claude-opus-5-5 --- .abcd/rules.json | 8 ++++ ...n-in-abcd-rules-json-recalls-on-a-fixed.md | 14 ++++++ .../cli/rules_dogfooding_recall_test.go | 45 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md create mode 100644 internal/surface/cli/rules_dogfooding_recall_test.go diff --git a/.abcd/rules.json b/.abcd/rules.json index e45580acd..fa09a98d6 100644 --- a/.abcd/rules.json +++ b/.abcd/rules.json @@ -79,9 +79,11 @@ "abcd", "ahoy", "banlist", + "build", "capture", "changelog", "completion", + "decide", "disembark", "docs", "embark", @@ -90,14 +92,20 @@ "history", "ideate", "identity", + "implement", + "inbox", "intent", "launch", "lint", "memory", + "mode", + "peers", "reading", + "report", "rules", "site", "spec", + "statusline", "update", "version" ], diff --git a/.abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md b/.abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md new file mode 100644 index 000000000..5d48cdd1c --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251357387577" +slug: "the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed" +severity: "minor" +category: "drift" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/rules.json" +--- + +The DOGFOODING domain in .abcd/rules.json recalls on a fixed keyword list that has drifted from the verb tree: AGENTS.md says it injects the go-run rule on a prompt naming abcd or any of its top-level verbs (the Available Commands list), but decide, implement, inbox, mode, peers, report and statusline are missing from its recall, so a prompt naming one of them never receives the rule that keeps a session off the stale plugin-root binary. Nothing holds the list to the tree, which is why it drifted. diff --git a/internal/surface/cli/rules_dogfooding_recall_test.go b/internal/surface/cli/rules_dogfooding_recall_test.go new file mode 100644 index 000000000..20373db32 --- /dev/null +++ b/internal/surface/cli/rules_dogfooding_recall_test.go @@ -0,0 +1,45 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +// TestDogfoodingRecallNamesEveryTopLevelVerb holds this repository's +// DOGFOODING domain to its documented contract (iss-2609251357387577): +// AGENTS.md says the go-run rule is injected on a prompt naming abcd or any of +// its top-level verbs, the Available Commands list of `abcd --help`. The recall +// list is hand-kept, so without this reader a verb added to the tree is a verb +// whose prompts never receive the rule — which is how seven of them drifted out. +func TestDogfoodingRecallNamesEveryTopLevelVerb(t *testing.T) { + data, err := os.ReadFile(filepath.Join(testRepoRoot(), ".abcd", "rules.json")) + if err != nil { + t.Fatal(err) + } + var rules struct { + Domains map[string]struct { + Recall []string `json:"recall"` + } `json:"domains"` + } + if err := json.Unmarshal(data, &rules); err != nil { + t.Fatalf("parse .abcd/rules.json: %v", err) + } + dom, ok := rules.Domains["DOGFOODING"] + if !ok { + t.Fatal(".abcd/rules.json declares no DOGFOODING domain") + } + root := NewRootCommand() + root.InitDefaultHelpCmd() + root.InitDefaultCompletionCmd() + for _, c := range root.Commands() { + if !c.IsAvailableCommand() && c.Name() != "help" { + continue + } + if !slices.Contains(dom.Recall, c.Name()) { + t.Errorf("top-level verb %q is in `abcd --help` but not in the DOGFOODING domain's recall list in .abcd/rules.json", c.Name()) + } + } +} From a0924c95f431b312a2a1be976a08f2c92027f9d3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:58:13 +0100 Subject: [PATCH 003/107] =?UTF-8?q?chore:=20resolve=20iss-2609251357387577?= =?UTF-8?q?=20=E2=80=94=20the=20DOGFOODING=20recall=20names=20every=20verb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251357387577 Assisted-by: Claude:claude-opus-5-5 --- ...ooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md (62%) diff --git a/.abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md b/.abcd/work/issues/resolved/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md similarity index 62% rename from .abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md rename to .abcd/work/issues/resolved/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md index 5d48cdd1c..ffab13231 100644 --- a/.abcd/work/issues/open/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md +++ b/.abcd/work/issues/resolved/iss-2609251357387577-the-dogfooding-domain-in-abcd-rules-json-recalls-on-a-fixed.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: ".abcd/rules.json" +resolution: "The DOGFOODING recall list names every top-level verb of abcd --help, and TestDogfoodingRecallNamesEveryTopLevelVerb holds the list to the root command's available verbs." +impact: internal +resolved_by: + commit: "5f3a9eb6" --- The DOGFOODING domain in .abcd/rules.json recalls on a fixed keyword list that has drifted from the verb tree: AGENTS.md says it injects the go-run rule on a prompt naming abcd or any of its top-level verbs (the Available Commands list), but decide, implement, inbox, mode, peers, report and statusline are missing from its recall, so a prompt naming one of them never receives the rule that keeps a session off the stale plugin-root binary. Nothing holds the list to the tree, which is why it drifted. + +## Grounds + +- pursued: we expect a verb added to the tree to fail go test until it joins the recall list, so the go-run rule reaches every prompt naming a verb; shown wrong if a verb in abcd --help is absent from the list while the suite is green From 92a7a1a76698b123e460b476f0e1fc6924e29a98 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:05:17 +0100 Subject: [PATCH 004/107] feat(banlist): an owned generated block, a phrase projection and a scan The private layer gains the three primitives a derived ban needs, all driving the guard's own engine so there is still one matcher: - PhrasePattern projects literal phrases into the POSIX ERE the guard enforces: metacharacters neutralised, whitespace-flexible across ASCII and Unicode space separators, non-ASCII letters written as their case fold orbit (the C-locale engine folds ASCII only), and bounded by a test on the neighbouring bytes rather than \b, which POSIX ERE leaves undefined and RE2 reads as ASCII-only. A phrase with fewer than three letters or digits is refused. - ScanText runs keyed patterns over arbitrary text through grep, as the guard does, and reports key, line and byte offset only. - SyncGeneratedBlock regenerates one owner's fenced block in the store, keeping every line outside it, refusing a legacy store, a broken or doubled fence, a key outside the owner's namespace or colliding with a hand-written entry, and any pattern the engine will not take as written. It never quotes a pattern. Part of itd-76 / spc-31. Assisted-by: Claude:claude-opus-5-5 --- internal/core/banlist/generated.go | 428 ++++++++++++++++++++++++ internal/core/banlist/generated_test.go | 260 ++++++++++++++ 2 files changed, 688 insertions(+) create mode 100644 internal/core/banlist/generated.go create mode 100644 internal/core/banlist/generated_test.go diff --git a/internal/core/banlist/generated.go b/internal/core/banlist/generated.go new file mode 100644 index 000000000..36fec566f --- /dev/null +++ b/internal/core/banlist/generated.go @@ -0,0 +1,428 @@ +package banlist + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// The generated block (itd-76, spc-31). A caller that DERIVES private entries from +// somewhere else — the sources corpus projects its confidential entries' titles, +// aliases and opted-in authors — owns one fenced block of the private store and +// regenerates it whole, while every hand-written or verb-added line outside the +// fence survives. The block's lines are ordinary keyed entries, so the committed +// guard enforces them with no change to its parser: the fence is two comment lines +// only this file's writer gives meaning to. +// +// Matching is this package's, not the caller's. The phrase-to-pattern projection +// (PhrasePattern) and the scan over arbitrary text (ScanText) both live here and +// both drive the guard's own engine, so what the pre-commit guard refuses and what +// a pre-share scan reports cannot disagree: there is one matcher, and it is grep. + +// KeyedPattern is one entry a caller hands the private layer: a non-secret key and +// the POSIX ERE the guard's engine enforces. The pattern never reaches any output. +type KeyedPattern struct { + Key string + Pattern string +} + +// Hit is one match found by ScanText: the key, the 1-based line, and the byte +// offset of the matched span in the scanned text. The span may begin one byte +// before the phrase itself, where the leading boundary consumed a non-alphanumeric +// neighbour. There is deliberately no field for the matched text: the type is the +// redaction, so a scan's report is safe to relay. +type Hit struct { + Key string `json:"key"` + Line int `json:"line"` + Offset int `json:"offset"` +} + +// GeneratedResult is the outcome of a generated-block sync. +type GeneratedResult struct { + // Path is the private store, repo-relative. + Path string `json:"path"` + // Owner names the block synced. + Owner string `json:"owner"` + // Entries counts the lines the block now holds. + Entries int `json:"entries"` + // Created reports that the store did not exist and this sync created it. + Created bool `json:"created"` + // Changed reports that the store's bytes changed; an idempotent sync is false. + Changed bool `json:"changed"` +} + +// The fence. A line whose ASCII-trimmed text begins with the begin prefix and the +// owner opens the block; the end prefix and the owner close it. They are comments, +// so every reader of the store that does not know about blocks reads them as such. +const ( + generatedBeginPrefix = "# >>> abcd-generated: " + generatedEndPrefix = "# <<< abcd-generated: " +) + +// minPhraseAlnum is the fewest letters or digits a projected phrase may carry. A +// shorter fragment matches inside ordinary names and words (iss-2609212142568782), +// and a ban that refuses legitimate text is one somebody switches off. +const minPhraseAlnum = 3 + +// unicodeSpaces are the non-ASCII members of Unicode's space-separator category, +// accepted between the words of a phrase. The engine runs in the C locale, where +// [[:space:]] is ASCII-only, so without these one non-breaking space between two +// words evades the ban (the class iss-2608311306535485 recorded against RE2). +var unicodeSpaces = []rune{0x00A0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, + 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x202F, 0x205F, 0x3000} + +// PhrasePattern projects one or more literal phrases into ONE pattern the private +// layer's engine (POSIX ERE, `grep -iE` under LC_ALL=C) enforces. It is the only +// place a literal becomes a pattern, so the guard and every scan read the same one. +// +// - Literal: every ERE metacharacter is neutralised, as a one-byte bracket +// expression where POSIX makes that portable and as `\^` for the one it does +// not. A title is never an expression. +// - Whitespace-flexible: words are split on Unicode white space and joined by one +// or more ASCII spaces OR non-ASCII space separators, so a no-break or thin +// space between two words is still the phrase. +// - Case-insensitive, Unicode-aware: the engine folds ASCII itself; a non-ASCII +// letter is written as the alternation of its simple case-fold orbit, which the +// byte-oriented C locale cannot fold. +// - Bounded by NEIGHBOURS, never by `\b`: the match needs a line edge or a byte +// that is not an ASCII letter or digit on either side. `\b` is undefined in POSIX +// ERE, and RE2's ASCII-only `\b` finds no boundary before a non-ASCII initial at +// the start of a word — a silent miss. The neighbour test errs the other way: +// a non-ASCII letter beside the phrase counts as a boundary, so the ban can +// match more than the phrase, never less. +// +// A phrase with fewer than three letters or digits is refused, as is one that is +// not valid UTF-8. The error never quotes the phrase. +func PhrasePattern(phrases ...string) (string, error) { + if len(phrases) == 0 { + return "", fmt.Errorf("%w: no phrase to project", ErrInvalidPattern) + } + alts := make([]string, 0, len(phrases)) + for i, ph := range phrases { + if !utf8.ValidString(ph) || strings.IndexByte(ph, 0) >= 0 { + return "", fmt.Errorf("%w: phrase %d is not valid UTF-8 text (the value is withheld)", ErrInvalidPattern, i+1) + } + alnum := 0 + for _, r := range ph { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + alnum++ + } + } + if alnum < minPhraseAlnum { + return "", fmt.Errorf("%w: phrase %d has fewer than %d letters or digits and would match ordinary text (the value is withheld)", + ErrInvalidPattern, i+1, minPhraseAlnum) + } + words := strings.FieldsFunc(ph, unicode.IsSpace) + enc := make([]string, len(words)) + for j, w := range words { + enc[j] = literalWord(w) + } + alts = append(alts, strings.Join(enc, phraseSeparator())) + } + return "(^|[^[:alnum:]])(" + strings.Join(alts, "|") + ")([^[:alnum:]]|$)", nil +} + +// phraseSeparator is the between-words expression: one or more white-space runes. +func phraseSeparator() string { + parts := []string{"[[:space:]]"} + for _, r := range unicodeSpaces { + parts = append(parts, string(r)) + } + return "(" + strings.Join(parts, "|") + ")+" +} + +// literalWord encodes one word as a literal for the engine. +func literalWord(w string) string { + var b strings.Builder + for _, r := range w { + switch { + case r == '^': + b.WriteString(`\^`) + case r == '\\': + b.WriteString(`[\]`) + case strings.ContainsRune(".[]()*+?{}|$", r): + b.WriteString("[" + string(r) + "]") + case r < utf8.RuneSelf: + b.WriteRune(r) + default: + b.WriteString(foldOrbit(r)) + } + } + return b.String() +} + +// foldOrbit writes a non-ASCII rune as the alternation of its simple case-fold +// orbit ("(É|é)"), or as itself when it has no other case. +func foldOrbit(r rune) string { + orbit := []string{string(r)} + for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) { + orbit = append(orbit, string(f)) + } + if len(orbit) == 1 { + return orbit[0] + } + return "(" + strings.Join(orbit, "|") + ")" +} + +// ScanText reports every place the given patterns match text, by key, line and +// byte offset, through the guard's own engine: `grep -inobaE` under LC_ALL=C, each +// pattern on STDIN (never argv), against the text in a 0600 temporary file removed +// afterwards. It is the read-only twin of the pre-commit guard, so a text this scan +// calls clean is a text the guard would pass, line for line. +// +// No grep is ErrNoEngine; a pattern the engine refuses is ErrInvalidPattern naming +// the key. Neither the pattern nor any matched byte is returned or quoted. +func ScanText(patterns []KeyedPattern, text []byte) ([]Hit, error) { + if len(patterns) == 0 { + return nil, nil + } + grepBinOnce.Do(func() { grepBin, _ = exec.LookPath("grep") }) + if grepBin == "" { + return nil, ErrNoEngine + } + f, err := os.CreateTemp("", "abcd-scan-*") + if err != nil { + return nil, err + } + defer os.Remove(f.Name()) + if err := f.Chmod(0o600); err != nil { + f.Close() + return nil, err + } + if _, err := f.Write(text); err != nil { + f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + var hits []Hit + for _, p := range patterns { + cmd := exec.Command(grepBin, "-inobaE", "-f", "-", "--", f.Name()) + cmd.Env = append(os.Environ(), "LC_ALL=C") + cmd.Stdin = strings.NewReader(p.Pattern + "\n") + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = nil // grep's diagnostics quote the expression + err := cmd.Run() + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit) && exit.ExitCode() == 1: + continue + case errors.As(err, &exit): + return nil, fmt.Errorf("%w for key %q: the guard's grep refuses it (the value is withheld)", ErrInvalidPattern, p.Key) + default: + return nil, fmt.Errorf("%w: %v", ErrNoEngine, err) + } + sc := bufio.NewScanner(&stdout) + sc.Buffer(make([]byte, 64<<10), 1<<20) + for sc.Scan() { + lineField, rest, ok := strings.Cut(sc.Text(), ":") + if !ok { + continue + } + offField, _, ok := strings.Cut(rest, ":") + if !ok { + continue + } + line, lerr := strconv.Atoi(lineField) + off, oerr := strconv.Atoi(offField) + if lerr != nil || oerr != nil { + continue + } + hits = append(hits, Hit{Key: p.Key, Line: line, Offset: off}) + } + } + return hits, nil +} + +// SyncGeneratedBlock regenerates the block owner owns in the private store from +// entries, and touches nothing outside it. +// +// Every key must sit in the owner's namespace (`/…`), be unique, and not be +// carried by a hand-written line outside the block; every pattern must be one the +// guard's engine accepts as written and must round-trip through the store's format. +// All of that is checked before anything is written, and no refusal quotes a +// pattern. The store's own contract holds as for AddPrivate: a legacy store with +// entries is refused rather than reinterpreted, the store must be gitignored, the +// write is contained and atomic at 0600, and the load-modify-write runs under the +// store's lock. +// +// An absent store with nothing to project stays absent — a machine that has not +// opted in is not opted in by an empty sync. An empty projection over an existing +// block empties the block and keeps its fence. A second sync of the same entries +// writes nothing (Changed is false). +func SyncGeneratedBlock(repoRoot, owner string, entries []KeyedPattern) (GeneratedResult, error) { + res := GeneratedResult{Path: PrivateRelPath, Owner: owner} + if !validKey(owner) || strings.Contains(owner, "/") { + return res, fmt.Errorf("%w: block owner %q", ErrInvalidKey, owner) + } + want := map[string]bool{} + for _, e := range entries { + if !validKey(e.Key) || !strings.HasPrefix(e.Key, owner+"/") { + return res, fmt.Errorf("%w: %q is outside the %q block's namespace (want %s/…)", ErrInvalidKey, e.Key, owner, owner) + } + if want[e.Key] { + return res, fmt.Errorf("%w: %q appears twice in the projection", ErrDuplicateKey, e.Key) + } + want[e.Key] = true + if strings.IndexByte(e.Pattern, 0) >= 0 { + return res, fmt.Errorf("%w for key %q: contains a NUL byte (the value is withheld)", ErrInvalidPattern, e.Key) + } + if fault, byEngine := checkPattern(e.Pattern); fault != faultNone { + return res, fmt.Errorf("%w for key %q: %s (the value is withheld)", ErrInvalidPattern, e.Key, patternRefusal(fault, byEngine)) + } + if !composedLineRoundTrips(e.Key, e.Pattern) { + return res, fmt.Errorf("%w for key %q: the composed entry does not round-trip (the value is withheld)", ErrInvalidPattern, e.Key) + } + } + res.Entries = len(entries) + + // Nothing to project and no store: nothing to do, and no tier to create. + if _, err := readPrivate(repoRoot); errors.Is(err, ErrNoStore) && len(entries) == 0 { + return res, nil + } + if err := requireIgnoredStore(repoRoot); err != nil { + return res, err + } + + err := withPrivateLock(repoRoot, func() error { + data, err := readPrivate(repoRoot) + switch { + case err == nil: + case errors.Is(err, ErrNoStore): + if len(entries) == 0 { + return nil + } + data = []byte(privateHeader) + res.Created = true + default: + return err + } + parsed, keyed, perr := parse(data) + if perr != nil { + return perr + } + if !keyed && len(parsed) > 0 { + return legacyStoreRefusal("sync a generated block into") + } + body := string(data) + if !keyed { + body = privateFormatDecl + "\n" + body + } + + lines := strings.Split(body, "\n") + begin, end, ferr := findBlock(lines, owner) + if ferr != nil { + return ferr + } + // Line numbers in parsed are 1-based over the ORIGINAL data; a prepended + // declaration shifts them by one, and only an entryless store gets one, so + // the collision check below reads the parse of the body actually written. + reparsed, _, perr := parse([]byte(body)) + if perr != nil { + return perr + } + for _, e := range reparsed { + inBlock := begin >= 0 && e.line-1 > begin && e.line-1 < end + if !inBlock && !e.unparsed && want[e.key] { + return fmt.Errorf("%w: %q is already a hand-written entry outside the generated block; remove it there first", ErrDuplicateKey, e.key) + } + } + + block := renderBlock(owner, entries) + var out []string + switch { + case begin >= 0: + out = append(out, lines[:begin]...) + out = append(out, block...) + out = append(out, lines[end+1:]...) + case len(entries) == 0: + return nil + default: + trimmed := strings.TrimRight(body, "\n") + out = append(strings.Split(trimmed, "\n"), "") + out = append(out, block...) + out = append(out, "") + } + next := strings.Join(out, "\n") + if next == string(data) { + return nil + } + if err := writePrivateStore(repoRoot, []byte(next)); err != nil { + return err + } + res.Changed = true + return nil + }) + if err != nil { + return GeneratedResult{Path: PrivateRelPath, Owner: owner}, err + } + return res, nil +} + +// findBlock locates owner's fence: the 0-based begin and end line indexes, or -1,-1 +// when there is none. Anything but exactly one begin followed by exactly one end is +// a malformed store, refused by line number. +func findBlock(lines []string, owner string) (begin, end int, err error) { + begin, end = -1, -1 + bm := generatedBeginPrefix + owner + em := generatedEndPrefix + owner + for i, raw := range lines { + l := trimLead(trimTrail(raw)) + switch { + case isMarker(l, bm): + if begin >= 0 { + return -1, -1, fmt.Errorf("%w: %s opens the %q generated block twice (lines %d and %d); remove one fence and re-run", + ErrMalformedStore, PrivateRelPath, owner, begin+1, i+1) + } + begin = i + case isMarker(l, em): + if end >= 0 { + return -1, -1, fmt.Errorf("%w: %s closes the %q generated block twice (lines %d and %d); remove one fence and re-run", + ErrMalformedStore, PrivateRelPath, owner, end+1, i+1) + } + end = i + } + } + switch { + case begin < 0 && end < 0: + return -1, -1, nil + case begin < 0 || end < 0 || end < begin: + return -1, -1, fmt.Errorf("%w: %s holds a broken %q generated block fence (an opening line needs one closing line after it); repair or remove the fence and re-run", + ErrMalformedStore, PrivateRelPath, owner) + } + return begin, end, nil +} + +// isMarker reports whether a trimmed line is the marker: the prefix, then a space or +// the end of the line (so owner "sources" never claims "sources2"'s fence). +func isMarker(line, marker string) bool { + if !strings.HasPrefix(line, marker) { + return false + } + rest := line[len(marker):] + return rest == "" || rest[0] == ' ' +} + +// renderBlock is the fence and its entries. +func renderBlock(owner string, entries []KeyedPattern) []string { + out := []string{ + generatedBeginPrefix + owner + " >>>", + "# Generated by abcd; every line between these fences is rewritten on each sync.", + "# Hand-written entries belong outside them.", + } + for _, e := range entries { + out = append(out, e.Key+" "+e.Pattern) + } + return append(out, generatedEndPrefix+owner+" <<<") +} diff --git a/internal/core/banlist/generated_test.go b/internal/core/banlist/generated_test.go new file mode 100644 index 000000000..703c85e90 --- /dev/null +++ b/internal/core/banlist/generated_test.go @@ -0,0 +1,260 @@ +package banlist + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func needGrep(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("grep"); err != nil { + t.Skip("grep unavailable: the enforcement engine cannot be driven") + } +} + +// phraseHits scans text with one phrase pattern through the enforcing engine. +func phraseHits(t *testing.T, pattern, text string) []Hit { + t.Helper() + hits, err := ScanText([]KeyedPattern{{Key: "k", Pattern: pattern}}, []byte(text)) + if err != nil { + t.Fatalf("ScanText: %v", err) + } + return hits +} + +// TestPhrasePatternMatchesThePhraseAndNotAWordContainingIt pins the projection a +// generated entry carries: the literal phrase, case-insensitive, whitespace-flexible, +// bounded by neighbours that are not letters or digits — and never `\b`, which the +// guard's POSIX engine does not define and RE2 reads as ASCII-only. +func TestPhrasePatternMatchesThePhraseAndNotAWordContainingIt(t *testing.T) { + needGrep(t) + p, err := PhrasePattern("Quiet Harbour Study") + if err != nil { + t.Fatal(err) + } + if strings.Contains(p, `\b`) { + t.Fatalf("pattern uses \\b: %q", p) + } + for _, tc := range []struct { + text string + want bool + }{ + {"see the Quiet Harbour Study today", true}, + {"QUIET HARBOUR STUDY", true}, + {"quiet\tharbour study.", true}, + {"(quiet harbour study)", true}, + {"quiet harbour studying", false}, + {"unquiet harbour study", false}, + {"quiet harbour", false}, + } { + got := len(phraseHits(t, p, tc.text)) > 0 + if got != tc.want { + t.Errorf("%q: matched=%v, want %v", tc.text, got, tc.want) + } + } +} + +// TestPhrasePatternIsUnicodeAware pins the two ASCII hazards the spec names. A +// non-breaking space between the words must not evade the ban, a non-ASCII initial +// must still be found at a word start (where `\b` under RE2 finds no boundary), and +// case folds for non-ASCII letters, which the C-locale engine cannot fold itself. +func TestPhrasePatternIsUnicodeAware(t *testing.T) { + needGrep(t) + p, err := PhrasePattern("Élan Özgür") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + text string + want bool + }{ + {"Élan Özgür", true}, + {"about élan özgür here", true}, + {"ÉLAN ÖZGÜR", true}, + {"Élan Özgür", true}, + {"xÉlan Özgür", false}, + } { + got := len(phraseHits(t, p, tc.text)) > 0 + if got != tc.want { + t.Errorf("%q: matched=%v, want %v", tc.text, got, tc.want) + } + } +} + +// TestPhrasePatternRefusesAFragment: a phrase with fewer than three letters or digits +// would ban ordinary text (iss-2609212142568782's hazard), so it is refused. +func TestPhrasePatternRefusesAFragment(t *testing.T) { + for _, bad := range []string{"", " ", "ab", "a b", "--"} { + if _, err := PhrasePattern(bad); err == nil { + t.Errorf("PhrasePattern(%q) accepted a fragment", bad) + } + } +} + +// TestPhrasePatternEscapesMetacharacters: a title is a literal, never an expression. +func TestPhrasePatternEscapesMetacharacters(t *testing.T) { + needGrep(t) + p, err := PhrasePattern("C++ (draft) v1.2 [x] a|b $5^") + if err != nil { + t.Fatal(err) + } + if fault, _ := checkPattern(p); fault != faultNone { + t.Fatalf("the guard's engine refuses the escaped pattern (fault %d)", fault) + } + if len(phraseHits(t, p, "C++ (draft) v1.2 [x] a|b $5^")) == 0 { + t.Error("the literal phrase does not match itself") + } + if len(phraseHits(t, p, "Cxx (draft) v1x2 [x] a|b $5^")) != 0 { + t.Error("a metacharacter was read as an expression") + } +} + +// TestScanTextReportsKeysAndOffsetsOnly: the scan names the entry and where it hit, +// never the text it matched. +func TestScanTextReportsKeysAndOffsetsOnly(t *testing.T) { + needGrep(t) + p, err := PhrasePattern("quiet harbour") + if err != nil { + t.Fatal(err) + } + hits, err := ScanText([]KeyedPattern{{Key: "sources/k1/title", Pattern: p}}, []byte("line one\nsee Quiet Harbour\n")) + if err != nil { + t.Fatal(err) + } + if len(hits) != 1 || hits[0].Key != "sources/k1/title" || hits[0].Line != 2 { + t.Fatalf("hits = %+v", hits) + } + if hits[0].Offset < 9 || hits[0].Offset > 13 { + t.Errorf("offset %d is not on line 2's match", hits[0].Offset) + } +} + +func readStore(t *testing.T, root string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(PrivateRelPath))) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// TestSyncGeneratedBlockKeepsHandLinesAndRegeneratesItsOwn pins the owned block: +// hand-written entries outside the markers survive every sync, the block is replaced +// whole, and an empty projection empties the block rather than leaving stale entries. +func TestSyncGeneratedBlockKeepsHandLinesAndRegeneratesItsOwn(t *testing.T) { + needGrep(t) + root := t.TempDir() + writePrivate(t, root, privateFormatDecl+"\n# my own\nhand-key widgetworks\n") + + p1, _ := PhrasePattern("quiet harbour") + p2, _ := PhrasePattern("second study") + res, err := SyncGeneratedBlock(root, "sources", []KeyedPattern{{Key: "sources/a/title", Pattern: p1}, {Key: "sources/b/title", Pattern: p2}}) + if err != nil { + t.Fatal(err) + } + if res.Entries != 2 || !res.Changed { + t.Fatalf("result = %+v", res) + } + body := readStore(t, root) + if !strings.Contains(body, "hand-key widgetworks") || !strings.Contains(body, "sources/a/title ") { + t.Fatalf("store after sync:\n%s", body) + } + rep, err := ListPrivate(root) + if err != nil || len(rep.Malformed) != 0 || len(rep.Entries) != 3 { + t.Fatalf("store does not read back healthy: %+v %v", rep, err) + } + + // Idempotent: the same projection writes nothing. + res, err = SyncGeneratedBlock(root, "sources", []KeyedPattern{{Key: "sources/a/title", Pattern: p1}, {Key: "sources/b/title", Pattern: p2}}) + if err != nil || res.Changed { + t.Fatalf("second sync changed the store: %+v %v", res, err) + } + + // A shrunk projection drops the dropped key and keeps the hand line. + if _, err := SyncGeneratedBlock(root, "sources", []KeyedPattern{{Key: "sources/b/title", Pattern: p2}}); err != nil { + t.Fatal(err) + } + body = readStore(t, root) + if strings.Contains(body, "sources/a/title") || !strings.Contains(body, "sources/b/title") || !strings.Contains(body, "hand-key widgetworks") { + t.Fatalf("store after shrink:\n%s", body) + } + + // An empty projection empties the block. + if _, err := SyncGeneratedBlock(root, "sources", nil); err != nil { + t.Fatal(err) + } + body = readStore(t, root) + if strings.Contains(body, "sources/b/title") || !strings.Contains(body, "hand-key widgetworks") { + t.Fatalf("store after empty sync:\n%s", body) + } + if strings.Count(body, generatedBeginPrefix) != 1 { + t.Fatalf("block markers duplicated or lost:\n%s", body) + } +} + +// TestSyncGeneratedBlockCreatesTheStoreOnlyWhenThereIsSomethingToBan: an absent +// store and nothing to project is left absent; something to project creates a keyed +// store at 0600. +func TestSyncGeneratedBlockCreatesTheStoreOnlyWhenThereIsSomethingToBan(t *testing.T) { + needGrep(t) + root := t.TempDir() + res, err := SyncGeneratedBlock(root, "sources", nil) + if err != nil || res.Changed { + t.Fatalf("empty projection on an absent store: %+v %v", res, err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(PrivateRelPath))); !os.IsNotExist(err) { + t.Fatalf("an empty projection created the store: %v", err) + } + p, _ := PhrasePattern("quiet harbour") + res, err = SyncGeneratedBlock(root, "sources", []KeyedPattern{{Key: "sources/a/title", Pattern: p}}) + if err != nil || !res.Created { + t.Fatalf("result = %+v %v", res, err) + } + fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(PrivateRelPath))) + if err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("store mode: %v %v", fi, err) + } + if !strings.HasPrefix(readStore(t, root), privateFormatDecl+"\n") { + t.Fatal("created store does not declare the keyed format on line 1") + } +} + +// TestSyncGeneratedBlockRefusals: the block's own integrity and the store's format +// are checked before anything is written, and no refusal quotes a pattern. +func TestSyncGeneratedBlockRefusals(t *testing.T) { + needGrep(t) + p, _ := PhrasePattern("quiet harbour") + good := []KeyedPattern{{Key: "sources/a/title", Pattern: p}} + for _, tc := range []struct { + name string + store string + in []KeyedPattern + want error + }{ + {"legacy store with entries", "somepattern\n", good, ErrLegacyStore}, + {"begin without end", privateFormatDecl + "\n" + generatedBeginPrefix + "sources >>>\n", good, ErrMalformedStore}, + {"two blocks", privateFormatDecl + "\n" + generatedBeginPrefix + "sources >>>\n" + generatedEndPrefix + "sources <<<\n" + generatedBeginPrefix + "sources >>>\n" + generatedEndPrefix + "sources <<<\n", good, ErrMalformedStore}, + {"key outside the namespace", privateFormatDecl + "\n", []KeyedPattern{{Key: "other/a", Pattern: p}}, ErrInvalidKey}, + {"hand key collides", privateFormatDecl + "\nsources/a/title x\n", good, ErrDuplicateKey}, + {"unusable pattern", privateFormatDecl + "\n", []KeyedPattern{{Key: "sources/a/title", Pattern: "[a-z-.]secretvalue"}}, ErrInvalidPattern}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writePrivate(t, root, tc.store) + _, err := SyncGeneratedBlock(root, "sources", tc.in) + if !errors.Is(err, tc.want) { + t.Fatalf("err = %v, want %v", err, tc.want) + } + if strings.Contains(err.Error(), "secretvalue") || strings.Contains(err.Error(), "harbour") { + t.Fatalf("refusal quotes a pattern: %v", err) + } + if readStore(t, root) != tc.store { + t.Fatal("a refused sync wrote the store") + } + }) + } +} From 84b6eb9e531b235c9f63293782ef23df084a9e53 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:05:31 +0100 Subject: [PATCH 005/107] feat(source): the sources corpus and its provenance ledger as abcd verbs `abcd source` is the personal core of the sources corpus (itd-76): a local-only, no-remote git repository under the user-level home (~/.abcd/sources by default, --corpus names another) holding a CSL-JSON bibliography, per-source folders whose location IS the classification, and one append-only influence ledger per repository. - init creates the corpus, refusing a location inside another working tree (adr-41: documents never ride into a repository). - add registers a document under confidential// or public// with its entry and custom block, and commits. The class is required and never defaulted; abcd fetches and converts nothing. A confidential entry's strings must project into a ban, and its key must not contain them, since the key is the one handle every output prints; --meta keeps those strings out of argv. - ledger appends {ts, repo, decision_ref, claim, source_key, locator, influence, cited_publicly: false} and commits; corrections are new lines. --flip N checks gate 1 (public folder, permission citable), refuses naming the gate, and appends the citation as a new line. - declassify is the visible git mv to public/ plus the entry update. - sync-banlist projects confidential titles and aliases (authors only under ban_authors) into the private banlist's generated block; cite-check scans text through the same projection and engine and reports key, field, line and offset only. - A corpus whose folders and entries disagree is refused by both, and no corpus is exit 3 on every verb but init. Both copies of the pre-commit guard (this repository's and the one ahoy scaffolds) run `abcd source sync-banlist --refresh` before reading the store, replacing the out-of-repo sync script the guard used to trust: no corpus is one line and the commit proceeds; a corpus with no binary, or a failed refresh, is one line and the store is checked as it stands. The consult and ingest pages call the verbs instead of hand-described scripts; the brief gains its 31-source chapter and register row, the consult, ingest, banlist, ahoy and abcd chapters follow, and the CLI reference, surface snapshot and release-gate manifest are regenerated. Part of itd-76 / spc-31. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 7 +- .../development/brief/04-surfaces/08-abcd.md | 5 +- .../brief/04-surfaces/13-consult.md | 100 ++-- .../brief/04-surfaces/14-ingest.md | 39 +- .../brief/04-surfaces/20-banlist.md | 9 +- .../brief/04-surfaces/31-source.md | 270 +++++++++ .abcd/development/brief/04-surfaces/README.md | 4 +- .abcd/development/release-gate/manifest.json | 7 +- .abcd/development/release/surface.json | 236 ++++++++ .githooks/pre-commit | 40 +- commands/consult.md | 64 +- commands/ingest.md | 79 ++- commands/source.md | 127 ++++ docs/reference/cli/commands.md | 156 +++++ docs/reference/terminology.md | 2 +- internal/core/ahoy/defaults/pre-commit | 34 ++ internal/core/banlist/hook_sources_test.go | 128 ++++ internal/core/source/add.go | 453 ++++++++++++++ internal/core/source/git.go | 60 ++ internal/core/source/guard.go | 121 ++++ internal/core/source/ledger.go | 252 ++++++++ internal/core/source/source.go | 449 ++++++++++++++ internal/core/source/source_test.go | 563 ++++++++++++++++++ internal/surface/cli/cli.go | 2 + internal/surface/cli/source.go | 549 +++++++++++++++++ internal/surface/cli/source_surface_test.go | 180 ++++++ 26 files changed, 3792 insertions(+), 144 deletions(-) create mode 100644 .abcd/development/brief/04-surfaces/31-source.md create mode 100644 commands/source.md create mode 100644 internal/core/banlist/hook_sources_test.go create mode 100644 internal/core/source/add.go create mode 100644 internal/core/source/git.go create mode 100644 internal/core/source/guard.go create mode 100644 internal/core/source/ledger.go create mode 100644 internal/core/source/source.go create mode 100644 internal/core/source/source_test.go create mode 100644 internal/surface/cli/source.go create mode 100644 internal/surface/cli/source_surface_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index e02c8a80b..648cc436f 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -126,9 +126,10 @@ user-scope directory for machine-local state. config.json machine config defaults (a later phase) memory/ user-scope memory (personal, cross-project — a later phase; the shipped store is repo-scope .abcd/memory/) - sources/ the local sources corpus /abcd:ingest and /abcd:consult - read. abcd NEVER creates it: absent means both verbs - say so and stop + sources/ the local sources corpus the source verb maintains and + /abcd:ingest and /abcd:consult read. Created only by + its explicit init: absent means every other verb and + both commands say so and stop load-limits the load check's per-machine limits (stray-minutes, extreme-load), read-only; abcd never creates it (itd-2609231434459890) diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 71110926e..a1ec0529a 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -64,8 +64,9 @@ here too. Binary-backed `/abcd:` verbs route through the transport-agnostic core (the CLI is the front door today; an MCP server follows later, per [adr-23](../../decisions/adrs/0023-transport-agnostic-core.md)). Not every verb -does: `consult` and `ingest` run entirely as host-side markdown over the -sources corpus and never invoke the binary. `prepare-this-repo` is the mixed +does: `consult` and `ingest` run as host-side markdown over the sources corpus, +and reach the binary only through the `source` verb, which makes every write to +the corpus. `prepare-this-repo` is the mixed case: its audit half runs `abcd lint`, and its adoption half is binary-backed too and writes — the identity verb's initialiser records the repo's identity block and registers the surfaces held to it, and the ahoy installer lays the hooks, the diff --git a/.abcd/development/brief/04-surfaces/13-consult.md b/.abcd/development/brief/04-surfaces/13-consult.md index 2369e8e80..8346ce2bd 100644 --- a/.abcd/development/brief/04-surfaces/13-consult.md +++ b/.abcd/development/brief/04-surfaces/13-consult.md @@ -11,10 +11,12 @@ The cost is one habit: when a source genuinely shapes a decision, the influence is recorded, and you are told which key was recorded against which decision, so the choice about ever citing it publicly stays yours. -It is **host-delegated**: no Go verb backs it and there is no bare-status -render. The workflow runs in the host agent from -[`commands/consult.md`](../../../../commands/consult.md), orchestrating the -corpus with `grep`, file reads, and `git`. +It is **host-delegated**: the workflow runs in the host agent from +[`commands/consult.md`](../../../../commands/consult.md), and there is no +`abcd consult` verb and no bare-status render of its own. Reading the corpus is +plain search with `grep` and file reads; every write — a ledger line, the +banlist sync, the pre-share scan — goes through the `abcd source` verbs +([`31-source.md`](31-source.md)). ## Sub-verbs @@ -40,8 +42,8 @@ any row it carries is still format-checked. The corpus lives at `~/.abcd/sources/`, each source held as a folder `//` under `confidential/` or `public/`. **The path IS the classification**: any hit under `confidential/` falls under the hard rule -below. Metadata lives in `sources.json` as CSL-JSON. If the corpus is absent, -the command says so and stops; it never creates it. +below. Metadata lives in `sources.json` as CSL-JSON. If the corpus is absent +(`abcd source` exits 3), the command says so and stops; it never creates it. ## Flow @@ -51,15 +53,16 @@ the command says so and stops; it never creates it. 2. **Read** matched files freely for conversation. The folder's class governs what may leave it. 3. **Record influence** whenever a source meaningfully shapes a decision - (supports, contradicts, supplies a method, or informs background): one line - appended to `~/.abcd/sources/ledger/.jsonl` and committed in the corpus - repo. The ledger is append-only, so corrections are new lines rather than - edits, and its `used_in` field traces the influence to the consuming document - in both directions. + (supports, contradicts, supplies a method, or informs background): the source + verb's ledger appends one line to this repository's ledger in the corpus and + commits it. The ledger is append-only, so corrections are new + lines rather than edits, and its `used_in` field traces the influence to the + consuming document in both directions. 4. **Tell the user which key was recorded against which decision.** The - `cited_publicly` flag is only ever flipped by hand, so a user cannot make - that decision about a ledger line they were never told exists. This step is - what turns the hard rule's second gate into something a human can operate. + `cited_publicly` flag is only ever flipped by the user, through the source + verb's ledger, so a user cannot make that decision about a ledger line they + were never told exists. This step is what turns the hard rule's second gate into + something a human can operate. ## The hard rule @@ -71,37 +74,34 @@ strings only, so this rule is the paraphrase layer. Refer generically ("a working paper on X"). Public citation requires **both** the source's permission status **and** the -ledger line's `cited_publicly` flag, and only the user flips the second, by -hand. Conversation with the user is exempt: confidential sources may be +ledger line's `cited_publicly` flag, and only the user flips the second; the +flip refuses, naming the gate, when the first is missing. Conversation with the user is exempt: confidential sources may be discussed freely there. ## Guard wiring -The rule is backed mechanically rather than trusted alone. The corpus ships -three programs. The first is the registrar on the write side: it takes a source -in, fixing its class at that moment and never afterwards. The second maintains a -generated block in the repo's untracked `.abcd/.work.local/private-names.txt`, -which the repo's pre-commit guard reads. The third scans a document before it is -committed or shared and exits non-zero when a confidential identifier is -present, naming only the CSL key so the report itself is safe to relay. - -Both guard programs refuse wholesale on a corpus whose classes disagree: if any -entry's declared confidentiality does not match the folder it sits in, neither -the sync nor the scan does any work, and each says which entries to repair -first. That is the safe direction, and the failure to watch for is the quiet one: -a refused sync leaves the generated block exactly as it was, so a newly added -confidential source is not covered by it, and a refused scan clears nothing. Read -the exit code, not the absence of complaint. - -**That file has two writers, and abcd is the other one.** -`.abcd/.work.local/private-names.txt` is abcd's own private banlist layer -(`banlist.PrivateRelPath`, itd-74 / spc-20), maintained by the banlist verb's -private-layer add and remove. The two writers coexist on a format -contract: the file's first line must be exactly `# abcd-banlist: keyed`, the -corpus script refuses a target whose first line is not that declaration, and it -confines itself to a fenced generated block, so hand-added and verb-added lines -outside that block survive. See [`20-banlist.md`](20-banlist.md) for the store -itself. +The rule is backed mechanically rather than trusted alone, by the `abcd source` +verbs. `sync-banlist` maintains a generated block in the repo's untracked +`.abcd/.work.local/private-names.txt`, which the repo's committed pre-commit guard +refreshes on every commit and then enforces. `cite-check` scans a document before +it is shared and exits non-zero when a confidential identifier is present, naming +only the key, so the report itself is safe to relay. Both read the same +projection of the corpus through the same matcher as the guard, so a scan and a +commit cannot disagree about what a source is called. + +Both refuse wholesale on a corpus whose classes disagree: if any entry's declared +confidentiality does not match the folder it sits in, neither the sync nor the +scan does any work, and each names the entries to repair first. That is the safe +direction, and the failure to watch for is the quiet one: a refused sync leaves +the generated block exactly as it was, so a newly added confidential source is +not covered by it, and a refused scan clears nothing. Read the exit code, not the +absence of complaint. + +**That file has two writers, and they share it by a fence.** The private store is +the banlist verb's private layer (itd-74 / spc-20). The sources sync owns one +fenced block in it and rewrites only that block, so hand-added and verb-added +lines outside the fence survive every sync. See [`20-banlist.md`](20-banlist.md) +for the store itself and [`31-source.md`](31-source.md) for the projection. **The mechanical layer is narrower than the hard rule, deliberately.** Patterns are derived from every confidential entry's title and aliases always, and from @@ -109,15 +109,8 @@ full author names only where that entry opts in with `custom.ban_authors`. Authors are inside the hard rule unconditionally, so on an entry that has not opted in the author names are held by the rule and by the reader applying it, and by no mechanical check at all. Set `ban_authors` on any entry whose -authorship is itself identifying. - -**The opt-in buys banlist coverage only, and not the scan.** The sync honours -`ban_authors` and puts the author names into the generated block, so the -pre-commit guard catches them; the document scan matches on titles and aliases -alone and reads no author field at all. A document naming a banned author and -nothing else therefore passes the scan and reports clean, and is then caught at -the commit. Treat a clean scan as covering what a source is called, never who -wrote it. +authorship is itself identifying; the guard and the scan then both catch the +names. ## Acceptance @@ -144,13 +137,14 @@ wrote it. ## Composition `/abcd:consult` is the read-and-record side of the sources system; -[`/abcd:ingest`](14-ingest.md) is the write side that adds a source. The two -share the corpus and its ledger. +[`/abcd:ingest`](14-ingest.md) is the write side that adds a source; and +[`/abcd:source`](31-source.md) is the binary both call. The three share the +corpus and its ledger. ## References - Plugin command: [`commands/consult.md`](../../../../commands/consult.md) -- Corpus store and its schema: `~/.abcd/sources/README.md` +- The verbs behind every write, and the store's schema: [`31-source.md`](31-source.md) - Write side of the same corpus: [`14-ingest.md`](14-ingest.md) - The banlist store the guard wiring shares: [`20-banlist.md`](20-banlist.md) - The trust boundary the hard rule restates: diff --git a/.abcd/development/brief/04-surfaces/14-ingest.md b/.abcd/development/brief/04-surfaces/14-ingest.md index 79a2cd0a2..b34b84241 100644 --- a/.abcd/development/brief/04-surfaces/14-ingest.md +++ b/.abcd/development/brief/04-surfaces/14-ingest.md @@ -11,11 +11,10 @@ It is the write side of the corpus at `~/.abcd/sources/`; `/abcd:consult` is the read side and the provenance recorder. It is a **host-delegated command**: a markdown workflow that runs in the host -agent, with **no Go verb** behind it. There is no top-level `abcd ingest` verb, -no bare-status render, and no CLI flags of its own. Every ingest path the binary -does have belongs to another verb, validates that verb's own input and never -writes this corpus; the generated CLI reference lists them, so this chapter -names none of them. +agent. There is no top-level `abcd ingest` verb, no bare-status render, and no +CLI flags of its own. The write it ends in is the source verb's add +([`31-source.md`](31-source.md)), which stores and classifies what the host hands +it and fetches and converts nothing. **Typing it at the CLI gets a second line that misdirects.** `abcd ingest` exits @@ -48,19 +47,22 @@ format-checked. ## What it does -The work is split. The corpus's own registrar script does the deterministic -half: fetch, convert, store, guard the ledger. The command supplies the -judgement half, in five steps. +The work is split. The source verb's add does the deterministic half: store the +document and its text under the class folder, write the entry, commit the corpus. +The command supplies the judgement half, fetching and converting included, in +five steps. 1. **Read the document first**, then extract the exact title, authors (`Family, Given`), year, venue, canonical URL, and CSL type. 2. **Decide class and key.** Web content is public by default; the signals for confidential are the user's own unpublished work, internal or NDA material, - AI-generated content, and a private repo's documentation. The key is - ``, checked for uniqueness against the - corpus metadata. -3. **Register** through the corpus registrar. A URL alone is enough: the script - fetches and stores the page. + AI-generated content, and a private repo's documentation. A public key is + ``; a confidential key is opaque, because + the key is the one handle every refusal and scan prints. The verb refuses a + duplicate key. +3. **Register** through the source verb's add, with the class declared and the + identifying strings in a metadata file rather than on the command line. A + URL alone registers a metadata stub. 4. **Quality-check the extraction.** Inspect the stored text for a sane word count and real prose, and repair the known failure modes by hand rather than leaving a stub that reads as a source. @@ -69,7 +71,8 @@ judgement half, in five steps. inherits the class by location, record an influence edge if a live decision motivated the ingest, and tell the user the key and class. -If the corpus is absent, the command says so and stops. It never creates it. +If the corpus is absent (`abcd source` exits 3), the command says so and stops. +It never creates it. ## Confidentiality contract @@ -100,15 +103,15 @@ they influenced. Both share the confidentiality guard and the store. The command prefers explicit registrar flags because it has better metadata in hand than a bare fetch would. There is **no one-argument quick path** into the -registrar: no binary sub-verb and no repo-shipped script provides one, so where -a reader finds such a command it is an operator-local convenience outside the -corpus contract. +registrar: the source verb's add requires the key and the class, so where a reader +finds such a command it is an operator-local convenience outside the corpus +contract. ## References - Plugin command: [`commands/ingest.md`](../../../../commands/ingest.md) - Read side of the same corpus: [`13-consult.md`](13-consult.md) -- Corpus contract: `~/.abcd/sources/README.md` +- The verb behind the write, and the corpus contract: [`31-source.md`](31-source.md) diff --git a/.abcd/development/brief/04-surfaces/20-banlist.md b/.abcd/development/brief/04-surfaces/20-banlist.md index 072717c43..b2789bfa1 100644 --- a/.abcd/development/brief/04-surfaces/20-banlist.md +++ b/.abcd/development/brief/04-surfaces/20-banlist.md @@ -100,10 +100,11 @@ change what every *other* line means. The store has a second writer, and the format declaration is what lets the two share it. The sources corpus derives patterns from its confidential entries and -maintains them inside a fenced generated block in the same file, refusing a target -that does not carry the declaration and leaving every line outside its block -untouched. So a hand-added private entry and the corpus sync write into one store -without either clobbering the other. See [`13-consult.md`](13-consult.md) for the +maintains them inside a fenced generated block in the same file, refusing a legacy +store that carries entries and leaving every line outside its block untouched. So +a hand-added private entry and the corpus sync write into one store without either +clobbering the other, and a hand-written key that collides with one the sync owns +is refused rather than overwritten. See [`31-source.md`](31-source.md) for the corpus side of that contract. Leading and trailing ASCII spaces and tabs are stripped, and so are a trailing diff --git a/.abcd/development/brief/04-surfaces/31-source.md b/.abcd/development/brief/04-surfaces/31-source.md new file mode 100644 index 000000000..5946ac946 --- /dev/null +++ b/.abcd/development/brief/04-surfaces/31-source.md @@ -0,0 +1,270 @@ +# `/abcd:source` — The Sources Corpus and Its Provenance Ledger + +Read anything that bears on a decision, including material you are not free to +name in public, and never let a helpful footnote name it. `/abcd:source` keeps a +personal corpus of documents in a local-only git repository, records which source +shaped which decision in an append-only ledger per repository, bans every +confidential source's names at commit time, and turns a ledger line into a public +citation only when a person decides to and the source permits it. + +The cost is two habits: declaring a source's class once, when it is added, and +recording an influence when a source genuinely shapes a decision. The payoff is +that nothing confidential can reach a commit by accident, and the day a paper is +published the whole influence trail behind it is already written. + +It is the binary half of the sources system. The host-delegated pages +[`/abcd:consult`](13-consult.md) and [`/abcd:ingest`](14-ingest.md) carry the +judgement (what to search for, which class a document is, which keywords matter) +and call these verbs for every write. + +## Sub-verbs + +> _Machine-checked (`surface_coverage`, spc-27): each row records the verb's +> adr-40 bucket (`lint` / `review` / `audit` / `gate`, or `—` for a +> non-assessment verb) and its existence (`shipped` / `staged`). The existence +> fact is verified against the committed command-tree snapshot in both +> directions. The bucket cell is checked for membership of the closed adr-40 +> vocabulary only: the snapshot carries no bucket field, so a bucket that is +> wrong but legal passes, and that cell stays a review-grain claim._ + +| Verb | Bucket | Status | +|---|---|---| +| `add` | — | shipped | +| `cite-check` | gate | shipped | +| `declassify` | — | shipped | +| `init` | — | shipped | +| `ledger` | — | shipped | +| `sync-banlist` | — | shipped | + +Bare `abcd source` is read-only: where the corpus is, how many sources sit in +each class, the ledgers and their line counts, and any entry whose folder and +metadata disagree. The corpus is the default one under the user-level home, +`~/.abcd/sources`, unless the invocation names another directory. + +## The store + +The corpus is a directory the person owns, outside every repository, and itself a +git repository with no remote. Its history is the tamper-evidence layer: every +write a verb makes is committed there. + +| path | holds | +|---|---| +| `sources.json` | a CSL-JSON array; each entry's `custom` block carries `confidential`, `permission_status`, `keywords`, `aliases`, `ban_authors` and `file` | +| `confidential//` | the original document, its extracted text as `text.md`, and anything derived from them | +| `public//` | the same shape for a freely citable source | +| `ledger/.jsonl` | one repository's influence records, one JSON object per line | + +**The folder is the classification.** A source's class is declared once, when it +is added: the add requires the class, confidential or public, and never defaults, +because a forgotten flag must not file a confidential document as public. Derived +artefacts (summaries, notes) live in the source's folder and inherit its class. +The entry's `confidential` flag mirrors the folder, and a corpus where the two +disagree (a folder moved by hand, an entry edited by hand) is refused wholesale by +the banlist sync and the scan, naming each key to repair. That is the safe +direction: the block already written keeps banning. + +A repository's ledger is named by the first twelve hex digits of its root commit, +which every clone and worktree of one repository shares whatever its directory is +called, unless the invocation names it. + +Creating the corpus is its own explicit step, and it refuses a location inside +another repository's working tree, where one `git add -A` would carry documents into it. Nothing +fetches and nothing converts: a Markdown or plain-text document is its own text, +any other needs its extracted text passed alongside, and a URL alone registers a +metadata stub. + +## Keys are the only handle output carries + +Every refusal, every guard message and every scan names a source by its key and +nothing else. So a confidential source's key must not name it: the add refuses a +key containing one of its aliases, its title or a longer title word, or an +author's name, and asks for an opaque key such as `conf2026a`. The identifying +strings themselves can travel in a metadata file or on stdin, so they stay out of +the process list and the shell history. + +## Recording influence, and citing + +Recording an influence appends `{ts, repo, decision_ref, claim, source_key, +locator, influence, cited_publicly}` with `cited_publicly` false, and commits it. There is no edit +path: a correction is a new line naming the line it corrects. + +A public citation needs both gates of +[adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md). The source grants +the right: its folder is under `public/` and its `permission_status` is +`citable`, the one value in the closed vocabulary that grants it. The person +exercises the right: the flip of line N checks the first gate, refuses naming it +when it fails, and on success appends a new line, a copy of line N with +`cited_publicly` true and `flips` naming N. An agent never runs the flip. The +binary cannot tell a person from an agent, so that rule lives in the command +pages. + +Declassification is how a confidential source becomes citable when it is +published: its folder moves `confidential/ → public/` by `git mv`, its entry's +flag and permission follow, and both land in one corpus commit. The next banlist +sync drops its strings, and its ledger lines become flippable. + +## The guard + +The banlist sync projects every confidential source into a fenced block of this +repository's untracked private banlist, `.abcd/.work.local/private-names.txt` (the +private layer of [`/abcd:banlist`](20-banlist.md)): the title and each alias +always, the authors only where the entry sets `ban_authors`. Lines outside the +fence, hand-written or verb-added, survive every sync. The committed pre-commit +guard, the copy this repository runs and the one `abcd ahoy` scaffolds alike, +runs the sync in its refresh mode before it reads the store, so the block is +never more than one commit stale, and then refuses a staged commit carrying a +banned string by key. + +Each phrase is projected into the pattern language the guard enforces: literal, +case-insensitive, and whitespace-flexible across ASCII and Unicode space +separators, with a non-ASCII letter written as its case-fold alternatives because +the guard's C-locale engine folds ASCII only. The boundary is a test on the +neighbouring bytes, never `\b`: POSIX extended expressions do not define it, and +RE2's ASCII-only reading finds no word start before a non-ASCII initial. The neighbour test +errs towards matching: a phrase beside a non-ASCII letter still counts as found. +A phrase with fewer than three letters or digits is refused, because it would +ban ordinary words. + +The scan reads a file or stdin through the same projection and the same engine +as the guard, so a text it calls clean is a text the guard would pass. It +reports each finding by key, field (`title`, `alias-N`, `author-N`), line and +byte offset, and never by the text matched, so its report is safe to relay. It +exits 1 when anything is found. + +## Absence is loud + +With no corpus at the configured location every verb but the creating one says +so on one line and exits 3, a code distinct from a refusal, so a script can tell +"nothing to check against" from "checked and clean". The guard says so on one line +and lets the commit proceed; the sync's refresh mode, which the guard runs, does +the same and exits 0. A corpus the guard cannot refresh (no binary found, or a +refresh that fails) is named on the commit, and the store is checked as it +stands. + +## What it cannot enforce + +The mechanical layer blocks literal identifying strings on one line of a staged +file. It cannot see a paraphrase that identifies a source without naming it, a +name split across a line break, or a spelling the entry does not carry (an +unrecorded alias, a decomposed Unicode form). Those are the consultation rule's +job and the human review before anything is published. Durability is bounded +too: a corpus with no remote survives the loss of its disk only through the +person's own backups and offline `git bundle` snapshots, which abcd documents and +cannot perform. + +## Acceptance + +- **Given** a document and its metadata, **when** it is added as confidential, + **then** the bibliography gains its CSL-JSON entry with the `custom` block, and + the document and its extracted text land under `confidential//`. +- **Given** a consulted source that shaped a decision, **when** the influence is + recorded, **then** one line is appended with `cited_publicly` false, and a + correction is another new line. +- **Given** confidential entries in the corpus, **when** a commit runs in a + managed repository, **then** the guard refreshes the generated block (titles + and aliases always, authors only under `ban_authors`) and refuses a commit + carrying a banned string. +- **Given** text about to leave the machine, **when** the scan reads it, + **then** offending sources are reported by key only. +- **Given** a ledger line whose source lacks citation permission, **when** a flip + is attempted, **then** it is refused naming the failing gate; with permission + present the flip succeeds as a new line. +- **Given** a machine with no corpus, **when** abcd runs in a managed repository, + **then** every corpus-dependent step says so on one line, and none fails. +- **Given** a confidential source that is published, **when** it is declassified, + **then** the next refresh drops its strings and its ledger lines become + eligible for the flip. + +## References + +- Plugin command: [`commands/source.md`](../../../../commands/source.md) +- The host-delegated halves: [`13-consult.md`](13-consult.md), + [`14-ingest.md`](14-ingest.md) +- The private banlist layer the guard shares: [`20-banlist.md`](20-banlist.md) +- The trust boundary: [adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md), + brief invariant 9 + ([`../02-constraints/03-invariants.md`](../02-constraints/03-invariants.md)) +- Consuming intent: [itd-76](../../intents/planned/itd-76-source-provenance-ledger.md) + + + +## Appendix: the shipped surface + +_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ + +### `abcd source` + +Sub-verbs: `abcd source add`, `abcd source cite-check`, `abcd source declassify`, `abcd source init`, `abcd source ledger`, `abcd source sync-banlist`. + +| Flag | Type | +|---|---| +| `--corpus` | string | + +### `abcd source add` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--alias` | stringArray | +| `--author` | stringArray | +| `--ban-authors` | bool | +| `--confidential` | bool | +| `--key` | string | +| `--keywords` | stringArray | +| `--meta` | string | +| `--permission` | string | +| `--public` | bool | +| `--text` | string | +| `--title` | string | +| `--type` | string | +| `--url` | string | +| `--venue` | string | +| `--year` | int | + +### `abcd source cite-check` + +Sub-verbs: none. + +Flags: none. + +### `abcd source declassify` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--permission` | string | + +### `abcd source init` + +Sub-verbs: none. + +Flags: none. + +### `abcd source ledger` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--claim` | string | +| `--corrects` | int | +| `--decision` | string | +| `--flip` | int | +| `--influence` | string | +| `--list` | bool | +| `--locator` | string | +| `--repo` | string | +| `--source` | string | +| `--used-in` | stringArray | + +### `abcd source sync-banlist` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--refresh` | bool | + + diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 16e52f892..43eed981a 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -43,6 +43,7 @@ are wiring rather than user-facing surface are listed separately under | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | | 29 | `/abcd:report` | shipped | Tell abcd about a defect or propose an enhancement from a repository it manages, into an inbox in your own account | [`29-report.md`](29-report.md) | | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | +| 31 | `/abcd:source` | shipped | Keep the documents you consult in a local corpus, record what each one changed, and ban the confidential ones' names at commit time | [`31-source.md`](31-source.md) | ## How much of this table a machine keeps honest @@ -175,7 +176,8 @@ documents is then an unknown command (iss-161). One file per verb, directly unde `abcd`, `ahoy`, `banlist`, `capture`, `consult`, `decide`, `disembark`, `docs`, `embark`, `guard`, `history`, `ideate`, `identity`, `implement`, `inbox`, `ingest`, `intent`, `launch`, `lint`, `memory`, `mode`, `peers`, -`prepare-this-repo`, `reading`, `report`, `site`, `update`, `version`. +`prepare-this-repo`, `reading`, `report`, `site`, `source`, `update`, +`version`. `abcd.md` is the bare `/abcd` status board; every other file is `/abcd:`. diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 3e4632dba..aba504c0a 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -46,6 +46,7 @@ ".abcd/development/brief/04-surfaces/27-implement.md", ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/30-inbox.md", + ".abcd/development/brief/04-surfaces/31-source.md", ".abcd/development/brief/04-surfaces/README.md", ".abcd/development/brief/02-constraints/04-naming.md", ".abcd/development/brief/05-internals/01-agents.md", @@ -82,10 +83,10 @@ "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" } ], - "checkerCount": 40, - "promptHash": "sha256:17fac8f45b9f29a778e950908bf97a4364426832a2d27b46a3368360f9124758", + "checkerCount": 41, + "promptHash": "sha256:5b047f39af341583b6bb4185614480cdbba243c4097902ed6ef3d040827afd90", "prompt": { - "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, capture, consult, decide, disembark, docs, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, report, site, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, press-release-composer,\nprinciple-distiller, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", + "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, capture, consult, decide, disembark, docs, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, report, site, source, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, press-release-composer,\nprinciple-distiller, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", "directionA": "Direction A. Read ${doc} fully. Extract every checkable claim\nabout the shipped surface (verbs, sub-verbs, flags, skill names, counts,\nfile layouts, \"abcd ships N ...\" statements) and verify each against\nreality. Return item=\"${doc}\" and the discrepancy list.", "directionB": "Direction B. The real surface \"${s.name}\" (${s.kind}) exists:\ninspect it (${s.probe}). Search the brief's surface chapters for its\ndocumented home (grep .abcd/development/brief/). If no brief row documents\nit — or the brief documents it under a wrong name/shape — that is a\ndiscrepancy. Return item=\"${s.name}\" and the discrepancy list (empty if\nproperly documented)." } diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 77ac43b01..13a98cb08 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1672,6 +1672,242 @@ } ] }, + { + "path": "abcd source", + "hidden": false, + "flags": [ + { + "name": "corpus", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd source add", + "hidden": false, + "flags": [ + { + "name": "alias", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "author", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "ban-authors", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "confidential", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "key", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "keywords", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "meta", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "permission", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "public", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "text", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "title", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "type", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "url", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "venue", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "year", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd source cite-check", + "hidden": false, + "flags": [] + }, + { + "path": "abcd source declassify", + "hidden": false, + "flags": [ + { + "name": "permission", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd source init", + "hidden": false, + "flags": [] + }, + { + "path": "abcd source ledger", + "hidden": false, + "flags": [ + { + "name": "claim", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "corrects", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + }, + { + "name": "decision", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "flip", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + }, + { + "name": "influence", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "list", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "locator", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "repo", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "source", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "used-in", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd source sync-banlist", + "hidden": false, + "flags": [ + { + "name": "refresh", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd spec", "hidden": false, diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 35b031756..974009f66 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -356,14 +356,38 @@ refuse_store_copy() { fi } -# Refresh the generated block from the user-level sources corpus when present -# (itd-76 dogfood): keeps the banlist as fresh as the current commit. No-op on -# machines without the corpus; a failed refresh falls back to the existing -# (possibly stale) banlist with a warning rather than blocking the commit. -sync_banlist="$HOME/.abcd/sources/bin/sync-banlist" -if [ -x "$sync_banlist" ]; then - "$sync_banlist" "$(pwd)" >/dev/null 2>&1 \ - || echo "pre-commit: warning — banlist refresh failed; checking existing banlist." >&2 +# --- itd-76: refresh the sources corpus's generated block ---------------------- +# The user-level sources corpus (`abcd source`) projects every confidential +# source's title and aliases (and, where an entry opts in, its authors) into a +# generated block of the private store below. The block is refreshed HERE, before +# the store is read, so the check is never more than this commit stale. +# +# Never a failure, never silent. No corpus is one line and the commit proceeds — +# CI, a fresh clone, and every machine that never made a corpus take that branch. +# A corpus with no abcd binary to refresh it, or a refresh that fails, is one line +# and the check runs against the store as it stands (the block already written +# keeps banning). The binary's own output is discarded: its refusals name keys +# only, and `abcd source sync-banlist` run by hand shows them. +# +# The binary is looked up on the pinned PATH (plus abcd.guardPath), then at the +# user-level install location ~/.local/bin, which is user-owned and not +# repository-scoped. The corpus location is the default one; relocating the +# user-level home is a separate design (itd-77). +sources_corpus="${HOME:-}/.abcd/sources" +if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then + echo "pre-commit: no sources corpus at ~/.abcd/sources — generated banlist block not refreshed (skipped)" >&2 +else + abcd_bin=$(command -v abcd 2>/dev/null || true) + if [ -z "$abcd_bin" ] && [ -x "$HOME/.local/bin/abcd" ]; then + abcd_bin="$HOME/.local/bin/abcd" + fi + if [ -z "$abcd_bin" ]; then + echo "pre-commit: warning — a sources corpus exists but no abcd binary is on the guard's PATH or in ~/.local/bin;" >&2 + echo " the generated banlist block was not refreshed, so the banlist is checked as it stands." >&2 + elif ! "$abcd_bin" source sync-banlist --refresh >/dev/null 2>&1; then + echo "pre-commit: warning — the sources banlist refresh failed (run 'abcd source sync-banlist' to see why);" >&2 + echo " the banlist is checked as it stands." >&2 + fi fi # A path that exists but is not a REGULAR FILE is tampering, not non-adoption: a diff --git a/commands/consult.md b/commands/consult.md index 674ca0790..2f74fb0ec 100644 --- a/commands/consult.md +++ b/commands/consult.md @@ -9,8 +9,10 @@ A local-only corpus at `~/.abcd/sources/` holds source documents (working papers, private-repo notes, PDFs, books) the agent may **consult** but must never **cite** publicly. Metadata lives in `sources.json` (CSL-JSON; the `custom` block carries `confidential`, `permission_status`, `keywords`, -`aliases`). Full details: `~/.abcd/sources/README.md`. If the corpus is -absent, say so and stop — this command never creates it. +`aliases`, `ban_authors`). Every write goes through the `abcd source` verbs +(`/abcd:source`); reading is plain search. First run +`"${CLAUDE_PLUGIN_ROOT}/abcd" source --json`: exit 3 means there is no corpus — +say so and stop, because this command never creates it. ## Hard rule (overrides convenience, always) @@ -46,35 +48,49 @@ To add a source, use `/abcd:ingest`. Whenever a source **meaningfully influences a decision** (supports it, contradicts it, supplies a method, or shapes background understanding — not -mere incidental reading), append ONE line to -`~/.abcd/sources/ledger/.jsonl`: +mere incidental reading), record ONE line: -```json -{"ts":"","repo":"","decision_ref":"","claim":"","source_key":"","locator":"","influence":"supports|contradicts|method|background","used_in":[""],"cited_publicly":false} +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source ledger --decision "" \ + --claim "" --source \ + --influence supports|contradicts|method|background \ + [--locator ""] [--used-in ]... --json ``` -`used_in` makes acknowledgment machine-readable in both directions: an idea -is traced to its source even when the consuming document only paraphrases -(public sources) or must stay silent (confidential sources). Fill it whenever -the influence landed in an identifiable document, not just a conversation. +The verb appends the line to this repository's ledger with +`cited_publicly: false` and commits it in the corpus. `--used-in` makes +acknowledgment machine-readable in both directions: an idea is traced to its +source even when the consuming document only paraphrases (public sources) or +must stay silent (confidential sources). Fill it whenever the influence landed +in an identifiable document, not just a conversation. -Then commit in the corpus repo: -`git -C ~/.abcd/sources add -A && git -C ~/.abcd/sources commit -m "ledger(): → "` - -The ledger is append-only: corrections are new lines, never edits. -`cited_publicly` is always written `false`; only the user flips it, by hand. +The ledger is append-only: a correction is a new line (`--corrects `). +**Never run `ledger --flip`** — flipping `cited_publicly` is the user's act, +and the verb refuses it anyway unless the source is public and citable. Always tell the user in conversation which key was recorded against which -decision, so they can decide about citing. +decision, and the line number, so they can decide about citing. ## Guard wiring -- On first use in a repo (and after any confidential source is added), run - `~/.abcd/sources/bin/sync-banlist `. It maintains a generated - block in the repo's untracked `.abcd/.work.local/private-names.txt`, which - the repo's pre-commit guard reads — leakage is then blocked mechanically, - not just by this command's rule. +- The repository's committed pre-commit guard runs + `abcd source sync-banlist --refresh` on every commit, which regenerates a + fenced block of confidential titles and aliases in the untracked + `.abcd/.work.local/private-names.txt`; leakage is then blocked mechanically, + not just by this command's rule. After adding or declassifying a source, run + `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist` so the block is current + before the next commit. - Before any document that drew on confidential material is committed, posted, - or otherwise shared, run `~/.abcd/sources/bin/cite-guard ` (exit 1 = - confidential identifier present; its report names only the CSL key, so the - report itself is safe to relay). + or otherwise shared, run `"${CLAUDE_PLUGIN_ROOT}/abcd" source cite-check ` + (exit 1 = a confidential identifier is present; its report names only the + CSL key, the field and the position, so the report itself is safe to relay). + It runs the same matcher as the guard, and it covers literal strings only. + +**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install +provisions the binary into the plugin root, so this is the rung that fires for a +plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails +too, you are in a source checkout of this repo, where — and only there — +`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a +binary on `PATH`, run `ahoy install` through whichever rung just resolved: +`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or +`go run ./cmd/abcd ahoy install` in a source checkout. diff --git a/commands/ingest.md b/commands/ingest.md index c7b0251d9..32f6c62b9 100644 --- a/commands/ingest.md +++ b/commands/ingest.md @@ -7,16 +7,18 @@ argument-hint: # Ingest a source Register a URL or local document in the corpus at `~/.abcd/sources/`. The -script does the deterministic half (fetch, convert, store, guard); you do the -judgment half (clean metadata, real keywords, confidentiality, quality check). -Corpus contract: `~/.abcd/sources/README.md`. The confidentiality hard rule -from `/abcd:consult` applies here in full. If the corpus is absent, say so and -stop — this command never creates it. +`abcd source add` verb does the deterministic half (store, classify, commit); +you do the judgment half (fetching, converting, clean metadata, real keywords, +confidentiality, quality check). abcd fetches and converts nothing. The +confidentiality hard rule from `/abcd:consult` applies here in full. First run +`"${CLAUDE_PLUGIN_ROOT}/abcd" source --json`: exit 3 means there is no corpus — +say so and stop, because this command never creates it. ## 1. Read the document first -- URL: WebFetch it (metadata + a skim of the content). A local fetch for - storage happens in step 3 regardless — WebFetch is for your judgment only. +- URL: fetch it (metadata and the content). Save what you will store — the + page or document, and its text as Markdown — to a scratch file; the verb + stores files you hand it and never fetches. - Local file: Read it (PDFs via the Read tool; big files: first pages suffice). Extract: exact title (no site suffixes), authors (each as `Family, Given`), @@ -30,35 +32,47 @@ docs), `motion_picture` (video). `--confidential`: the user says so; it is their own unpublished/submitted work; internal or NDA material; AI-generated content (never citable); a private repo's documentation. When confidential, ask the user for every - identifying name variant (`--aliases` — repo names, codenames, domains) and + identifying name variant (aliases — repo names, codenames, domains) and pick `--permission`: `no-public-citation`, `internal-never-cite`, - `ai-generated-never-cite`, or `ask-author`. -- **Titles of confidential entries become banned phrases** (whole title, - whitespace-flexible). For internal artifacts whose natural title reads like + `ai-generated-never-cite`, or `ask-author`. The class is declared once, here; + `add` refuses to guess it. +- **Titles and aliases of confidential entries become banned phrases** (whole + phrase, case-insensitive, whitespace-flexible), and each needs at least three + letters or digits. For internal artifacts whose natural title reads like normal prose, register a distinctive title instead — e.g. "meeting-notes-2026-07 (internal)" — so the ban cannot trip on legitimate - text. -- **Key**: ``, lowercase ASCII (e.g. - `naur1985theory`). Check uniqueness: `jq -r '.[].id' ~/.abcd/sources/sources.json`. + text. Authors are banned only with `--ban-authors`, for a source whose + authorship is itself identifying. +- **Key.** Public: ``, lowercase ASCII + (e.g. `naur1985theory`). Confidential: an **opaque** key (e.g. `conf2026a`) — + the key is the one handle every refusal and scan prints, and the verb refuses + one that contains an alias, the title or a longer title word, or an author's + name. A duplicate key is refused. ## 3. Register ```sh -~/.abcd/sources/bin/add-source --key --title "" \ - --type <csl-type> [--author "Family, Given"]... [--year YYYY] \ - --keywords "<k1, k2, ...>" [--aliases "a,b"] [--confidential] \ - [--permission <status>] [--url <url>] [file] +"${CLAUDE_PLUGIN_ROOT}/abcd" source add [file] --key <key> --confidential|--public \ + --type <csl-type> [--year YYYY] [--venue <venue>] [--url <url>] \ + [--permission <status>] [--ban-authors] [--text <extracted.md>] \ + --meta <meta.json> --json ``` -- URL only (no file): `--url` makes the script fetch and store the page. -- Local file + known URL: pass both; the URL is recorded, the file stored. +- `meta.json` carries the identifying strings, so they stay out of argv and + shell history: `{"title": "…", "aliases": ["…"], "author": [{"family": "…", + "given": "…"}], "keywords": ["…"]}`. For a public source `--title`, + `--author "Family, Given"`, `--alias` and `--keywords` work as flags too. +- A `.md` or `.txt` file is its own text. Any other file (a PDF, a saved page) + needs `--text` with the Markdown you extracted. A URL with no file registers + a metadata stub. - **Keywords are the retrieval surface** — write 5–10 from having actually read the piece: topics, named tools/techniques, the claims it makes. Never generic filler ("AI", "software"). -There is no one-argument quick path, and none is assumed here: `add-source` with -explicit flags is the registrar's front door, and the better one anyway, since -you have just read the source and hold metadata a bare fetch could not recover. +There is no one-argument quick path, and none is assumed here: `source add` +with explicit flags is the registrar's front door, and the better one anyway, +since you have just read the source and hold metadata a bare fetch could not +recover. ## 4. Quality-check the extraction @@ -66,16 +80,27 @@ Check `~/.abcd/sources/<class>/<key>/text.md` — word count sane, real prose present. Known failure modes: `.mhtml` (unsupported → stub; extract by hand), saved SPA/artifact pages whose content sits HTML-escaped in a wrapper (unescape entities, `pandoc -t gfm`, rebuild text.md below its frontmatter, -commit in the corpus repo). If the source is webloc/link-only there is no body +commit in the corpus repo — a hand repair to a stored file is the one write +the verb does not make). If the source is webloc/link-only there is no body — a metadata+URL stub is correct. ## 5. Close out -- Confidential ingest → run `~/.abcd/sources/bin/sync-banlist <repo-root>` in - every guarded repo the session touches. +- Confidential ingest → run `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist` + in every guarded repo the session touches (the guard also refreshes it on + the next commit). - If the user wants a summary or review kept: write it to the source's own folder (`summary.md`, notes as siblings) — derived artifacts inherit the source's class by location, never anywhere else. - If the ingest was motivated by a live decision, record the influence edge in - the ledger per `/abcd:consult`. + the ledger per `/abcd:consult` (`abcd source ledger`). - Tell the user the key and class you registered. + +**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install +provisions the binary into the plugin root, so this is the rung that fires for a +plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails +too, you are in a source checkout of this repo, where — and only there — +`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a +binary on `PATH`, run `ahoy install` through whichever rung just resolved: +`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or +`go run ./cmd/abcd ahoy install` in a source checkout. diff --git a/commands/source.md b/commands/source.md new file mode 100644 index 000000000..6e33cca0d --- /dev/null +++ b/commands/source.md @@ -0,0 +1,127 @@ +--- +name: source +description: Maintain the personal sources corpus (the user-level home's sources store, default ~/.abcd/sources) and its provenance ledger by invoking the abcd binary — register a source, record an influence, project confidential names into this repo's private banlist, and scan text before it leaves the machine. Bare invocation is a read-only render. +argument-hint: "[init | add [document] --key K --confidential|--public [...] | declassify <key> | ledger [--decision D --claim C --source K --influence I | --flip N | --list] | sync-banlist [--refresh] | cite-check <file|->]" +--- + +# `/abcd:source` — the sources corpus and its ledger + +The corpus holds documents the user may consult but is not always free to cite: +working papers, private notes, purchased reports. The folder a source sits in — +`confidential/<key>/` or `public/<key>/` — is its classification. The ledger +records which source shaped which decision in this repository. `/abcd:consult` +and `/abcd:ingest` are the judgement halves and call these verbs for every write. + +## The hard rule + +Never write a confidential source's title, aliases, authors or any identifying +description into anything tracked by git or sent anywhere external: commits, +pull-request and issue text, docs, code comments, pasted output. Name a source by +its **key** only — every output of this verb does. Conversation with the user is +exempt. + +## Render the corpus (bare, read-only) + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source --json +``` + +Report `present`, the `confidential` and `public` counts, each ledger's `repo` and +`lines`, and every entry in `problems` by key and reason. A non-zero `remotes` is +a warning to relay: documents and ledgers never leave this machine. Exit 3 means +there is no corpus: say so, and stop. Only the user decides to create one, with +`source init`. + +## Register a source + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source add <document> --key <key> --confidential|--public \ + --meta <meta.json> [--type <csl-type>] [--year YYYY] [--venue V] [--url U] \ + [--permission <status>] [--ban-authors] [--text <extracted.md>] --json +``` + +- The class is required and never defaulted. Ask the user when in doubt. +- For a confidential source, put the title, aliases and authors in a `--meta` + JSON file (`{"title": …, "aliases": […], "author": [{"family": …, "given": …}], + "keywords": […]}`) so they stay out of argv, and choose an **opaque** key + (`conf2026a`): the verb refuses a key that contains an alias, the title or a + longer title word, or an author's name. +- abcd fetches and converts nothing. A `.md` or `.txt` document is its own text; + anything else needs `--text` with the extracted text; a URL alone registers a + metadata stub. + +## Record an influence + +When a source meaningfully shapes a decision (supports, contradicts, supplies a +method, or shapes background): + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source ledger --decision "<ADR id | intent id | date | text>" \ + --claim "<what was decided>" --source <key> --influence supports|contradicts|method|background \ + [--locator "§2"] [--used-in <path>]... --json +``` + +The line lands with `cited_publicly: false` and is committed in the corpus. A +correction is a new line: add `--corrects <N>`. Tell the user which key was +recorded against which decision and the line number. + +**Never run `ledger --flip`.** It is the user's act of citing a line publicly. It +checks the source first — folder under `public/` and `permission_status` citable +— and refuses naming the failing gate. If the user asks how to cite, tell them +the command; do not run it for them. + +`ledger --list` prints the ledger, numbered. The ledger is named by this +checkout's root commit unless `--repo` names it. + +## Keep the guard current + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist --json +``` + +Projects every confidential source's title and aliases (authors only under +`ban_authors`) into this repository's untracked private banlist, as a fenced block +it owns. The committed pre-commit guard runs `sync-banlist --refresh` on every +commit, so running it by hand matters after an add or a declassification, before +the next commit. A refusal naming keys means folders and entries disagree: repair +those entries (the block already written keeps banning meanwhile). + +## Scan before sharing + +Before any text that drew on confidential material is posted, shared or pasted +anywhere git does not gate: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source cite-check <file> --json # or - for stdin +``` + +Exit 1 means a finding: each names the source's key, the field (`title`, +`alias-N`, `author-N`), the line and the byte offset — never the matched text, so +the report is safe to relay. Reword generically and scan again. A clean scan +covers literal strings only, never an identifying paraphrase. + +## Declassify a published source + +When the user says a confidential source is now published and citable: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" source declassify <key> [--permission <status>] --json +``` + +The folder moves to `public/` in one corpus commit; then run `sync-banlist`. + +## No corpus + +Every verb but `init` exits 3 with one line when there is no corpus at the +location. Say so and stop; never create a corpus the user did not ask for. + +**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install +provisions the binary into the plugin root, so this is the rung that fires for a +plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails +too, you are in a source checkout of this repo, where — and only there — +`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a +binary on `PATH`, run `ahoy install` through whichever rung just resolved: +`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or +`go run ./cmd/abcd ahoy install` in a source checkout. + +**User input:** $ARGUMENTS diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index e280509a8..9a2c0b0bd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1497,6 +1497,162 @@ Gate the built site: provenance, hero drift, banned tokens, snippets, the refere --out string built output directory to check (rendered first if absent) (default "site") ``` +### `abcd source` + +The personal sources corpus and its provenance ledger (bare renders its state, read-only) + +**Usage:** `abcd source` + +The personal sources corpus: documents you may consult, a CSL-JSON bibliography, and one +append-only influence ledger per repository, in a local-only git repository with no +remote (~/.abcd/sources by default; --corpus names another). The folder a source sits +in — confidential/<key>/ or public/<key>/ — is its classification. + +Consult freely, cite deliberately: confidential entries are projected into this +repository's untracked private banlist (sync-banlist), which the committed pre-commit +guard refreshes and enforces; cite-check clears text before it leaves the machine; and +a ledger line becomes a public citation only when the source permits it AND a person +flips the line (adr-41). No output names a confidential source except by key. + +Bare `abcd source` is read-only. Exit 3 when there is no corpus, on every verb but init. + +**Flags:** + +``` + --corpus string the corpus directory (absolute; default ~/.abcd/sources) +``` + +#### `abcd source add` + +Register a source: its entry, the document and its text under its class folder + +**Usage:** `abcd source add [document] [flags]` + +Register a source: write its CSL-JSON entry (with the custom block), store the document +as original.<ext> and its extracted text as text.md under confidential/<key>/ or +public/<key>/, and commit the corpus. The class is declared here, once: exactly one +of --confidential or --public is required. abcd converts nothing and fetches nothing: +a Markdown or text document is its own text, any other needs --text, and a URL +alone registers a metadata stub. + +A confidential entry's title, aliases and (under --ban-authors) authors become banned +phrases, so each must hold at least three letters or digits, and its key must not +contain any of them — the key is what every refusal and scan prints. Pass those +strings with --meta FILE (or --meta - on stdin) to keep them out of argv and shell +history. + +**Flags:** + +``` + --alias stringArray another identifying name for a confidential source (repeatable) + --author stringArray an author, "Family, Given" or a literal name (repeatable) + --ban-authors also ban the authors' names (a confidential source whose authorship is itself identifying) + --confidential file the source under confidential/ (exclusive with --public) + --key string the source key: lowercase, opaque for a confidential source (e.g. conf2026a) + --keywords stringArray retrieval keywords, comma-separated (repeatable) + --meta string a JSON file (or - for stdin) with "title", "aliases", "author" and "keywords" + --permission string permission_status: citable | no-public-citation | internal-never-cite | ai-generated-never-cite | ask-author (default by class) + --public file the source under public/ (exclusive with --confidential) + --text string the extracted text of a non-text document + --title string the exact title (for a confidential source prefer --meta) + --type string the CSL item type (default document) + --url string the canonical URL (recorded, never fetched) + --venue string the container title (journal, site, publisher) + --year int the year of issue +``` + +#### `abcd source cite-check` + +Scan text for confidential sources; report offenders by key only (exit 1 on a hit) + +**Usage:** `abcd source cite-check <file|->` + +Scan a file, or stdin with -, for every confidential source's title, aliases and +opted-in authors, through the private banlist's matcher — the engine the pre-commit +guard runs. Offenders are reported by key, field, line and byte offset, never by the +text matched, so the report is safe to relay. Exit 1 when anything is found. + +#### `abcd source declassify` + +Move a published confidential source to public/ — a visible, committed move + +**Usage:** `abcd source declassify <key> [flags]` + +Declassify a confidential source once it is published: `git mv` its folder from +confidential/ to public/ and set the entry's confidential flag and permission_status +(citable unless --permission says otherwise), in one corpus commit. The next +sync-banlist drops its strings, and its ledger lines become flippable. + +**Flags:** + +``` + --permission string permission_status after the move: citable | no-public-citation | internal-never-cite | ai-generated-never-cite | ask-author (default citable) +``` + +#### `abcd source init` + +Create an empty corpus: a no-remote git repository with an empty bibliography + +**Usage:** `abcd source init` + +Create the corpus at its location (0700): a git repository with no remote, an empty +sources.json and a README, in one commit. Refuses an existing corpus, a non-empty +directory, and a location inside another repository's working tree. + +#### `abcd source ledger` + +Append an influence record to this repository's ledger; --flip cites one; --list reads it + +**Usage:** `abcd source ledger [flags]` + +Append one influence record — {ts, repo, decision_ref, claim, source_key, locator, +influence, cited_publicly: false} — to this repository's ledger in the corpus, and +commit it. The ledger is append-only: a correction is a new line (--corrects N). + +--flip N is the person's act of citing line N publicly. It checks the source first +(adr-41 gate 1: the folder is public/ and permission_status is citable), refuses +naming the failing gate, and on success appends a NEW line with cited_publicly true. +An agent never runs it. --list prints the ledger, numbered. + +The repository is named by its root commit's first twelve hex digits unless --repo +names it. + +**Flags:** + +``` + --claim string what was decided or claimed + --corrects int the line this record corrects + --decision string the decision influenced: a DECISIONS.md date, an ADR or intent id, or free text + --flip int cite line N publicly (the person's act; checks the source's permission first) + --influence string the influence: supports | contradicts | method | background + --list print the ledger, numbered (read-only) + --locator string where in the source (pp., §) + --repo string the ledger's repository handle (default: this checkout's root commit, 12 hex digits) + --source string the source key + --used-in stringArray a repository-relative path the influence landed in (repeatable) +``` + +#### `abcd source sync-banlist` + +Project confidential titles and aliases into this repository's untracked private banlist + +**Usage:** `abcd source sync-banlist [flags]` + +Regenerate the corpus's block in this repository's untracked private banlist +(.abcd/.work.local/private-names.txt, the banlist verb's private layer): every +confidential source's title and aliases, and its authors under ban_authors, as +whitespace-flexible, case-insensitive phrases. Lines outside the block survive. +A corpus whose folders and entries disagree is refused and nothing is written. + +--refresh is the pre-commit guard's mode: with no corpus it says so on one line and +exits 0. + +**Flags:** + +``` + --refresh the guard's mode: an absent corpus is a one-line notice and exit 0 +``` + ### `abcd spec` Native spec store; bare invocation is read-only status diff --git a/docs/reference/terminology.md b/docs/reference/terminology.md index 7ca1955fc..52514f74a 100644 --- a/docs/reference/terminology.md +++ b/docs/reference/terminology.md @@ -62,7 +62,7 @@ deep-linked. | **Orchestration** | Coordinating which agents run, in what order, and how control and results flow between them.[^orch] | **ADAPTS** — abcd is *host-delegated* (adr-25): the deterministic core prepares work and hands a prompt to the host's own dispatch. abcd owns the prompt; the host owns models, credentials, and execution — the orchestration-substrate role is deliberately declined. | | **Policy-as-code** | Expressing enforcement policy in declarative, machine-evaluated form, decoupling policy decisions from application logic; the policy *engine* is the component that decides grant or deny.[^pac] | **USES** — abcd's policy is committed JSON configuration: per-repo rules, docs-lint, record-lint, and backend-seam selection, evaluated deterministically in the core. The record practises this without using the phrase; this page is where the mapping is made. | | **Prompt injection** | A vulnerability where user prompts or ingested content alter an LLM's behaviour in unintended ways — directly or indirectly (OWASP LLM01:2025).[^pi] | **USES** — recorded defences on both sides of the boundary: agents that read untrusted input must carry injection-canary fixtures (the itd-5 discipline), and a verb that mutates state fails closed on anything not exactly recognised. The canary lint (reserved code PQ006) and automated canary execution are recorded design targets, not yet shipped. | -| **Retrieval-augmented generation (RAG)** | Combining a generator with a retriever over an external index, so generation draws on non-parametric knowledge.[^rag] | **ADAPTS** — the sources corpus takes the script-first shape: per-source folders, extracted text, grep-based consult. It is a user-tier script MVP, not shipped product behaviour (absorption into the core is tracked as iss-27); retrieval is recorded as a pluggable seam (iss-26) where a RAG backend is one opt-in adapter, never the default. | +| **Retrieval-augmented generation (RAG)** | Combining a generator with a retriever over an external index, so generation draws on non-parametric knowledge.[^rag] | **ADAPTS** — the sources corpus takes the plain-store shape: per-source folders, extracted text, grep-based consult, maintained by the `abcd source` verbs; retrieval is recorded as a pluggable seam (iss-26) where a RAG backend is one opt-in adapter, never the default. | | **Sandboxing** | An OS-enforced boundary restricting an agent's filesystem and network access, so it can act autonomously without unrestricted host access.[^sand] | **ADAPTS** — abcd's containment is structural rather than OS-level: read-only probes proven by before-and-after tree hashes, a destination safety gate that refuses directories abcd did not produce, and refusals that write nothing. OS-level sandboxes belong to the host. | | **Tamper-evidence** | Append-only log integrity via Merkle trees, so any instance of a log can be proven a superset of any earlier instance.[^tamper] | **WATCHING** — receipts are hash-anchored and manifests verified today; a compliance-grade hash chain over conversation and edit history is a draft (itd-16), and tamper-evident receipts are tracked as iss-141. | | **Tool use** | A model emits structured, schema-conformant calls to declared functions; the application executes them and returns results ("function calling" in some vendors' vocabulary).[^tooluse] | **ADAPTS** — abcd sits on the other side of the mechanism: it *is* the tool. A single binary of verbs over a transport-agnostic core that returns structured results and knows nothing about who called it (adr-23); thin front doors render those results per surface. | diff --git a/internal/core/ahoy/defaults/pre-commit b/internal/core/ahoy/defaults/pre-commit index 259564d51..94020e775 100644 --- a/internal/core/ahoy/defaults/pre-commit +++ b/internal/core/ahoy/defaults/pre-commit @@ -363,6 +363,40 @@ refuse_store_copy() { fi } +# --- the sources corpus: refresh its generated block ----------------------------- +# The user-level sources corpus (`abcd source`) projects every confidential +# source's title and aliases (and, where an entry opts in, its authors) into a +# generated block of the private store below. The block is refreshed HERE, before +# the store is read, so the check is never more than this commit stale. +# +# Never a failure, never silent. No corpus is one line and the commit proceeds — +# CI, a fresh clone, and every machine that never made a corpus take that branch. +# A corpus with no abcd binary to refresh it, or a refresh that fails, is one line +# and the check runs against the store as it stands (the block already written +# keeps banning). The binary's own output is discarded: its refusals name keys +# only, and `abcd source sync-banlist` run by hand shows them. +# +# The binary is looked up on the pinned PATH (plus abcd.guardPath), then at the +# user-level install location ~/.local/bin, which is user-owned and not +# repository-scoped. The corpus location is the default one; relocating the +# user-level home is a separate design. +sources_corpus="${HOME:-}/.abcd/sources" +if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then + echo "pre-commit: no sources corpus at ~/.abcd/sources — generated banlist block not refreshed (skipped)" >&2 +else + abcd_bin=$(command -v abcd 2>/dev/null || true) + if [ -z "$abcd_bin" ] && [ -x "$HOME/.local/bin/abcd" ]; then + abcd_bin="$HOME/.local/bin/abcd" + fi + if [ -z "$abcd_bin" ]; then + echo "pre-commit: warning — a sources corpus exists but no abcd binary is on the guard's PATH or in ~/.local/bin;" >&2 + echo " the generated banlist block was not refreshed, so the banlist is checked as it stands." >&2 + elif ! "$abcd_bin" source sync-banlist --refresh >/dev/null 2>&1; then + echo "pre-commit: warning — the sources banlist refresh failed (run 'abcd source sync-banlist' to see why);" >&2 + echo " the banlist is checked as it stands." >&2 + fi +fi + # A path that exists but is not a REGULAR FILE is tampering, not non-adoption: a # symlink (which `git add -f` can commit, so a checkout materialises it) swaps or # empties the guard, and a directory or FIFO there would take the "absent" branch diff --git a/internal/core/banlist/hook_sources_test.go b/internal/core/banlist/hook_sources_test.go new file mode 100644 index 000000000..2cc7cf95e --- /dev/null +++ b/internal/core/banlist/hook_sources_test.go @@ -0,0 +1,128 @@ +package banlist + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The pre-commit guard refreshes the sources corpus's generated block before it +// checks anything (itd-76 AC3, AC6). Both copies of the guard carry the step — the +// one this repository runs and the template `abcd ahoy` scaffolds into a managed +// repository — so every case runs against both. +func guardCopies(t *testing.T) map[string]string { + t.Helper() + hook := locateHook(t) + top := filepath.Dir(filepath.Dir(hook)) + return map[string]string{ + "repo": hook, + "template": filepath.Join(top, "internal", "core", "ahoy", "defaults", "pre-commit"), + } +} + +// newGuardRepo is newHookRepo with a chosen copy of the guard installed. +func newGuardRepo(t *testing.T, hookPath, body string) *hookRepo { + t.Helper() + r := newHookRepo(t, body) + src, err := os.ReadFile(hookPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(r.dir, ".git", "hooks", "pre-commit"), src, 0o755); err != nil { + t.Fatal(err) + } + return r +} + +// fakeAbcd puts an `abcd` on the guard's PATH (through the repo-local guardPath +// extension) that records its arguments and then runs body, and returns the file +// the arguments land in. +func fakeAbcd(t *testing.T, r *hookRepo, body string) string { + t.Helper() + bin := t.TempDir() + args := filepath.Join(t.TempDir(), "args") + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > '" + args + "'\n" + body + "\n" + if err := os.WriteFile(filepath.Join(bin, "abcd"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + r.git("config", "--local", "abcd.guardPath", bin) + return args +} + +func makeCorpusDir(t *testing.T) { + t.Helper() + home := os.Getenv("HOME") + if home == "" { + t.Skip("no HOME") + } + if err := os.MkdirAll(filepath.Join(home, ".abcd", "sources"), 0o700); err != nil { + t.Fatal(err) + } +} + +// TestPreCommitHook_NoCorpusSaysSoAndProceeds is AC6 at the guard: no corpus is one +// line naming the skip, and the commit proceeds. +func TestPreCommitHook_NoCorpusSaysSoAndProceeds(t *testing.T) { + for name, hook := range guardCopies(t) { + t.Run(name, func(t *testing.T) { + r := newGuardRepo(t, hook, "") + r.write("note.md", "hello\n") + r.git("add", "note.md") + blocked, out := r.commit() + if blocked { + t.Fatalf("commit blocked with no corpus\n%s", out) + } + if strings.Count(out, "no sources corpus") != 1 { + t.Fatalf("the guard does not say, once, that the corpus is absent\n%s", out) + } + }) + } +} + +// TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking is AC3 at the guard: with +// a corpus present the guard runs `abcd source sync-banlist --refresh` BEFORE it +// reads the store, so an entry the refresh writes is enforced on this very commit. +func TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking(t *testing.T) { + for name, hook := range guardCopies(t) { + t.Run(name, func(t *testing.T) { + makeCorpusDir(t) + r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key zzunrelated\n") + args := fakeAbcd(t, r, "printf '# abcd-banlist: keyed\\nsources/conf2026a/title widgetworks\\n' > .abcd/.work.local/private-names.txt") + r.write("note.md", "the widgetworks draft\n") + r.git("add", "note.md") + blocked, out := r.commit() + got, err := os.ReadFile(args) + if err != nil { + t.Fatalf("the guard did not run abcd\n%s", out) + } + if strings.TrimSpace(string(got)) != "source sync-banlist --refresh" { + t.Fatalf("the guard ran abcd with %q", got) + } + if !blocked || !strings.Contains(out, "sources/conf2026a/title") { + t.Fatalf("the refreshed entry was not enforced on this commit\n%s", out) + } + }) + } +} + +// TestPreCommitHook_AFailedRefreshWarnsAndChecksTheStoreAsItStands: a refresh that +// fails is named, never fatal, and never silent; the existing store still guards. +func TestPreCommitHook_AFailedRefreshWarnsAndChecksTheStoreAsItStands(t *testing.T) { + for name, hook := range guardCopies(t) { + t.Run(name, func(t *testing.T) { + makeCorpusDir(t) + r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key widgetworks\n") + fakeAbcd(t, r, "exit 2") + r.write("note.md", "the widgetworks draft\n") + r.git("add", "note.md") + blocked, out := r.commit() + if !strings.Contains(out, "refresh failed") { + t.Fatalf("a failed refresh was silent\n%s", out) + } + if !blocked || !strings.Contains(out, "hand-key") { + t.Fatalf("the existing store stopped guarding after a failed refresh\n%s", out) + } + }) + } +} diff --git a/internal/core/source/add.go b/internal/core/source/add.go new file mode 100644 index 000000000..43dd75801 --- /dev/null +++ b/internal/core/source/add.go @@ -0,0 +1,453 @@ +package source + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "unicode" + + "github.com/intentdriven/abcd/internal/core/banlist" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// AddRequest registers one source. Class is required and has no default: the +// classification is declared once, at ingestion, and a forgotten flag must not file +// a confidential document as public. +type AddRequest struct { + Corpus string + Key string + Title string + // Type is the CSL item type; "document" when empty. + Type string + Class string + // Permission is the permission_status; by class when empty (confidential: + // no-public-citation, public: citable). + Permission string + Authors []Name + Year int + Venue string + URL string + Keywords []string + Aliases []string + BanAuthors bool + // Original is the document to store (any format); Text is its extracted text. + // A Markdown or plain-text original is its own text. + Original string + Text string +} + +// AddResult is what a registration or a declassification did, named by key. +type AddResult struct { + Key string `json:"key"` + Class string `json:"class"` + Permission string `json:"permission_status"` + Folder string `json:"folder"` + Files []string `json:"files"` +} + +// maxDocumentBytes caps a stored original or text (trust boundary). +const maxDocumentBytes = 512 << 20 + +var ( + cslTypeRe = regexp.MustCompile(`^[a-z][a-z_-]{0,39}$`) + extRe = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`) +) + +// textExts are originals that are their own extracted text. +var textExts = []string{".md", ".markdown", ".txt"} + +// oneLine reports whether s is non-empty after trimming and holds no line break. +func oneLine(s string) bool { + return strings.TrimSpace(s) != "" && !strings.ContainsAny(s, "\r\n") +} + +// Add registers a document: the CSL-JSON entry with its custom block, the original +// and its extracted text under <class>/<key>/, committed in the corpus repository. +// Every check runs before the first write, and no refusal quotes a title, alias or +// author. +func Add(req AddRequest) (AddResult, error) { + c, err := Load(req.Corpus) + if err != nil { + return AddResult{}, err + } + entry, text, ext, err := validateAdd(c, req) + if err != nil { + return AddResult{}, err + } + + res := AddResult{Key: entry.ID, Class: req.Class, Permission: entry.Custom.PermissionStatus, + Folder: req.Class + "/" + entry.ID, Files: []string{}} + err = withLock(c.Dir, func() error { + // Re-read under the lock: a concurrent add of the same key loses here. + fresh, err := Load(c.Dir) + if err != nil { + return err + } + if _, dup := fresh.Lookup(entry.ID); dup || len(fresh.folders[entry.ID]) > 0 { + return fmt.Errorf("%w: %q", ErrDuplicateSource, entry.ID) + } + before, err := os.ReadFile(filepath.Join(c.Dir, SourcesFile)) + if err != nil { + return err + } + folder := filepath.Join(c.Dir, req.Class, entry.ID) + rollback := func() { + _ = os.RemoveAll(folder) + _ = fsutil.WriteFileAtomic(filepath.Join(c.Dir, SourcesFile), before, 0o600) + _, _ = corpusGit(c.Dir, "reset", "-q", "--", SourcesFile, res.Folder) + } + if err := os.MkdirAll(folder, 0o700); err != nil { + return fmt.Errorf("%w: cannot create %s: %v", ErrCorpusInvalid, res.Folder, err) + } + if req.Original != "" { + name := "original" + ext + if err := copyFile(req.Original, filepath.Join(folder, name)); err != nil { + rollback() + return err + } + res.Files = append(res.Files, res.Folder+"/"+name) + } + body := "---\nkey: " + entry.ID + "\n---\n\n" + text + if text != "" && !strings.HasSuffix(body, "\n") { + body += "\n" + } + if err := fsutil.WriteFileAtomic(filepath.Join(folder, TextFile), []byte(body), 0o600); err != nil { + rollback() + return err + } + res.Files = append(res.Files, res.Folder+"/"+TextFile) + raw, err := json.Marshal(entry) + if err != nil { + rollback() + return err + } + if err := writeSources(c.Dir, append(fresh.raw, raw)); err != nil { + rollback() + return err + } + if err := commit(c.Dir, "source: add "+entry.ID+" ("+req.Class+")", SourcesFile, res.Folder); err != nil { + rollback() + return err + } + return nil + }) + if err != nil { + return AddResult{}, err + } + return res, nil +} + +// validateAdd checks a registration and builds its entry, the text to store, and +// the original's extension. +func validateAdd(c *Corpus, req AddRequest) (Entry, string, string, error) { + bad := func(format string, a ...any) error { + return fmt.Errorf("%w: "+format, append([]any{ErrInvalidEntry}, a...)...) + } + if !keyRe.MatchString(req.Key) { + return Entry{}, "", "", bad("the key is not a source key (lowercase letters, digits, '.', '_' or '-', at most 64; the value is withheld)") + } + if req.Class != ClassConfidential && req.Class != ClassPublic { + return Entry{}, "", "", bad("declare the class — confidential or public; it is decided once, here, and never defaulted") + } + if !oneLine(req.Title) { + return Entry{}, "", "", bad("the title is empty or spans lines") + } + typ := req.Type + if typ == "" { + typ = "document" + } + if !cslTypeRe.MatchString(typ) { + return Entry{}, "", "", bad("the CSL type %q is not a CSL type name", typ) + } + conf := req.Class == ClassConfidential + perm := req.Permission + if perm == "" { + perm = PermissionCitable + if conf { + perm = PermissionNoPublicCitation + } + } + if !validPermission(perm) { + return Entry{}, "", "", bad("permission status %q is not one of %s", perm, strings.Join(Permissions, ", ")) + } + if conf && perm == PermissionCitable { + return Entry{}, "", "", bad("a confidential source cannot be citable; register it public, or declassify it when it is published") + } + for i, a := range req.Aliases { + if !oneLine(a) { + return Entry{}, "", "", bad("alias %d is empty or spans lines", i+1) + } + } + for i, k := range req.Keywords { + if !oneLine(k) { + return Entry{}, "", "", bad("keyword %d is empty or spans lines", i+1) + } + } + for i, n := range req.Authors { + if nameText(n) == "" || strings.ContainsAny(n.Family+n.Given+n.Literal, "\r\n") { + return Entry{}, "", "", bad("author %d is empty or spans lines", i+1) + } + } + if req.URL != "" && !oneLine(req.URL) { + return Entry{}, "", "", bad("the URL spans lines") + } + if req.Venue != "" && !oneLine(req.Venue) { + return Entry{}, "", "", bad("the venue spans lines") + } + if req.Year != 0 && (req.Year < 1000 || req.Year > 9999) { + return Entry{}, "", "", bad("the year %d is not a four-digit year", req.Year) + } + if req.BanAuthors && !conf { + return Entry{}, "", "", bad("ban-authors applies to a confidential source only") + } + + entry := Entry{ID: req.Key, Type: typ, Title: strings.TrimSpace(req.Title), Author: req.Authors, + ContainerTitle: req.Venue, URL: req.URL, + Custom: Custom{Confidential: conf, PermissionStatus: perm, Keywords: trimAll(req.Keywords), + Aliases: trimAll(req.Aliases), BanAuthors: req.BanAuthors}} + if req.Year != 0 { + entry.Issued = &Date{DateParts: [][]int{{req.Year}}} + } + if conf { + // Every string the ban will carry must project now: a phrase the guard cannot + // hold would otherwise surface at the next sync as a refusal of the whole corpus. + if _, err := projectEntry(entry); err != nil { + return Entry{}, "", "", err + } + if field := identifyingKey(entry); field != "" { + return Entry{}, "", "", fmt.Errorf("%w: the key contains the source's %s (the key is withheld here for that reason), and the key is the one handle every refusal and scan prints; choose an opaque key such as conf2026a", + ErrIdentifyingKey, field) + } + } + if _, dup := c.Lookup(req.Key); dup || len(c.folders[req.Key]) > 0 { + return Entry{}, "", "", fmt.Errorf("%w: %q", ErrDuplicateSource, req.Key) + } + + var text, ext string + if req.Original != "" { + fi, err := os.Stat(req.Original) + if err != nil || !fi.Mode().IsRegular() { + return Entry{}, "", "", bad("the document is not a readable regular file") + } + if fi.Size() > maxDocumentBytes { + return Entry{}, "", "", bad("the document is over %d bytes", maxDocumentBytes) + } + if e := strings.ToLower(filepath.Ext(req.Original)); extRe.MatchString(e) { + ext = e + } + entry.Custom.File = "original" + ext + } + switch { + case req.Text != "": + b, err := fsutil.ReadGuarded(req.Text, maxDocumentBytes) + if err != nil { + return Entry{}, "", "", bad("the extracted text is not a readable regular file") + } + text = string(b) + case req.Original != "" && slices.Contains(textExts, ext): + b, err := fsutil.ReadGuarded(req.Original, maxDocumentBytes) + if err != nil { + return Entry{}, "", "", bad("the document cannot be read as text") + } + text = string(b) + case req.Original != "": + return Entry{}, "", "", bad("a %q original needs its extracted text (pass the text file); abcd converts nothing itself", ext) + case req.URL == "": + return Entry{}, "", "", bad("nothing to store: give the document, its extracted text, or its URL") + } + return entry, text, ext, nil +} + +// trimAll trims each value. +func trimAll(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, len(in)) + for i, s := range in { + out[i] = strings.TrimSpace(s) + } + return out +} + +// nameText is a CSL name as one string. +func nameText(n Name) string { + if n.Literal != "" { + return strings.TrimSpace(n.Literal) + } + return strings.TrimSpace(strings.TrimSpace(n.Given) + " " + strings.TrimSpace(n.Family)) +} + +// fold lowercases s and keeps only its letters and digits, for the key check. +func fold(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// identifyingKey names the field a confidential entry's key would reveal, or "". +// The key is printed by the guard and by cite-check, so it must not carry an alias, +// the title or one of its longer words, or an author's name. +func identifyingKey(e Entry) string { + key := fold(e.ID) + has := func(s string, min int) bool { + f := fold(s) + return len([]rune(f)) >= min && strings.Contains(key, f) + } + for _, a := range e.Custom.Aliases { + if has(a, 3) { + return "alias" + } + } + if has(e.Title, 3) { + return "title" + } + for _, w := range strings.Fields(e.Title) { + if has(w, 5) { + return "title" + } + } + for _, n := range e.Author { + for _, part := range []string{n.Family, n.Literal} { + if has(part, 3) { + return "author" + } + } + } + return "" +} + +// copyFile copies a document into the corpus, creating the destination exclusively +// at 0600. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("%w: the document cannot be opened", ErrInvalidEntry) + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(out, io.LimitReader(in, maxDocumentBytes)); err != nil { + out.Close() + return err + } + return out.Close() +} + +// Declassify is the visible move of a published confidential source: the folder +// moves confidential/<key> → public/<key> by `git mv`, the entry's confidential flag +// and permission_status follow, and the corpus commits both as one change. The next +// banlist sync drops the key's strings, and its ledger lines become flippable when +// the permission set here grants citation (citable, the default). +func Declassify(corpus, key, permission string) (AddResult, error) { + c, err := Load(corpus) + if err != nil { + return AddResult{}, err + } + e, ok := c.Lookup(key) + if !ok { + return AddResult{}, fmt.Errorf("%w: %q", ErrUnknownSource, key) + } + if c.Class(key) != ClassConfidential || !e.Custom.Confidential { + return AddResult{}, fmt.Errorf("%w: %q is not a confidential source in confidential/", ErrInvalidEntry, key) + } + if permission == "" { + permission = PermissionCitable + } + if !validPermission(permission) { + return AddResult{}, fmt.Errorf("%w: permission status %q is not one of %s", ErrInvalidEntry, permission, strings.Join(Permissions, ", ")) + } + res := AddResult{Key: key, Class: ClassPublic, Permission: permission, Folder: ClassPublic + "/" + key, Files: []string{}} + err = withLock(c.Dir, func() error { + fresh, err := Load(c.Dir) + if err != nil { + return err + } + idx := -1 + for i, en := range fresh.Entries { + if en.ID == key { + idx = i + } + } + if idx < 0 || fresh.Class(key) != ClassConfidential { + return fmt.Errorf("%w: %q changed underneath the declassification", ErrInvalidEntry, key) + } + var m map[string]any + if err := json.Unmarshal(fresh.raw[idx], &m); err != nil { + return fmt.Errorf("%w: the entry does not read as a JSON object", ErrCorpusInvalid) + } + custom, _ := m["custom"].(map[string]any) + if custom == nil { + custom = map[string]any{} + } + custom["confidential"] = false + custom["permission_status"] = permission + m["custom"] = custom + raw, err := json.Marshal(m) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(c.Dir, ClassPublic), 0o700); err != nil { + return err + } + from, to := ClassConfidential+"/"+key, ClassPublic+"/"+key + if _, err := corpusGit(c.Dir, "mv", "--", from, to); err != nil { + return fmt.Errorf("%w: moving the folder failed (is it committed?): %v", ErrCorpusInvalid, err) + } + fresh.raw[idx] = raw + if err := writeSources(c.Dir, fresh.raw); err != nil { + return err + } + // git mv staged the rename already; the old path no longer exists to name. + return commit(c.Dir, "source: declassify "+key+" ("+permission+")", SourcesFile, to) + }) + if err != nil { + return AddResult{}, err + } + return res, nil +} + +// projectEntry is one confidential entry's patterns: title and aliases always, +// authors only under ban_authors. +func projectEntry(e Entry) ([]banlist.KeyedPattern, error) { + var out []banlist.KeyedPattern + add := func(suffix, field string, phrases ...string) error { + p, err := banlist.PhrasePattern(phrases...) + if err != nil { + return fmt.Errorf("%w: %s of %q cannot be banned: %v", ErrInvalidEntry, field, e.ID, err) + } + out = append(out, banlist.KeyedPattern{Key: "sources/" + e.ID + "/" + suffix, Pattern: p}) + return nil + } + if err := add("title", "the title", e.Title); err != nil { + return nil, err + } + for i, a := range e.Custom.Aliases { + if err := add(fmt.Sprintf("alias-%d", i+1), fmt.Sprintf("alias %d", i+1), a); err != nil { + return nil, err + } + } + if e.Custom.BanAuthors { + for i, n := range e.Author { + phrases := []string{nameText(n)} + if n.Literal == "" && n.Given != "" && n.Family != "" { + phrases = append(phrases, strings.TrimSpace(n.Family)+", "+strings.TrimSpace(n.Given)) + } + if err := add(fmt.Sprintf("author-%d", i+1), fmt.Sprintf("author %d", i+1), phrases...); err != nil { + return nil, err + } + } + } + return out, nil +} diff --git a/internal/core/source/git.go b/internal/core/source/git.go new file mode 100644 index 000000000..0b8c6ea48 --- /dev/null +++ b/internal/core/source/git.go @@ -0,0 +1,60 @@ +package source + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/intentdriven/abcd/internal/gitutil" +) + +// corpusGit runs git in the corpus repository. +// +// The environment is scrubbed of every repo-selection variable — the load-bearing +// case is a call made from inside another repository's pre-commit hook, where git +// has exported GIT_DIR and GIT_INDEX_FILE for THAT repository and an unscrubbed +// child would commit corpus paths into it. Global config stays in effect, because +// the corpus commits under the person's own identity. +// +// Hooks are switched off (core.hooksPath=/dev/null): the corpus is a local store +// whose whole content is what a name guard exists to keep out of project +// repositories, and a global hook dispatcher applying one here would refuse the +// corpus its own documents. +func corpusGit(dir string, args ...string) (string, error) { + full := append([]string{"-c", "core.hooksPath=/dev/null", "-C", dir}, args...) + cmd := exec.Command("git", full...) + cmd.Env = gitutil.ScrubbedEnv() + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if len(msg) > 2048 { + msg = msg[:2048] + } + return "", fmt.Errorf("git %s: %w (%s)", args[0], err, msg) + } + return stdout.String(), nil +} + +// commit stages paths (additions, modifications and removals alike) and commits +// them. Nothing to commit is not an error. +func commit(dir, msg string, paths ...string) error { + if _, err := corpusGit(dir, append([]string{"add", "-A", "--"}, paths...)...); err != nil { + return fmt.Errorf("%w: staging the change failed: %v", ErrCorpusInvalid, err) + } + _, err := corpusGit(dir, "diff", "--cached", "--quiet") + if err == nil { + return nil + } + // Exit 1 means "there are staged changes"; anything else is a failure. + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != 1 { + return fmt.Errorf("%w: reading the staged change failed: %v", ErrCorpusInvalid, err) + } + if _, err := corpusGit(dir, "commit", "-q", "-m", msg); err != nil { + return fmt.Errorf("%w: the corpus commit failed (is a git identity configured?): %v", ErrCorpusInvalid, err) + } + return nil +} diff --git a/internal/core/source/guard.go b/internal/core/source/guard.go new file mode 100644 index 000000000..0b3662455 --- /dev/null +++ b/internal/core/source/guard.go @@ -0,0 +1,121 @@ +package source + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/core/banlist" +) + +// BlockOwner names the generated block the corpus owns in a repository's private +// banlist, and heads every key in it: `sources/<key>/<field>`. +const BlockOwner = "sources" + +// Projection is every confidential source's patterns, in bibliography order: the +// title and aliases always, the authors only where the entry opts in with +// ban_authors. A source is confidential by its FOLDER; a corpus where folder and +// entry disagree never reaches here (every caller requires consistency first). +func (c *Corpus) Projection() ([]banlist.KeyedPattern, error) { + var out []banlist.KeyedPattern + for _, e := range c.Entries { + if c.Class(e.ID) != ClassConfidential { + continue + } + pats, err := projectEntry(e) + if err != nil { + return nil, err + } + out = append(out, pats...) + } + return out, nil +} + +// confidentialCount counts the sources under confidential/. +func (c *Corpus) confidentialCount() int { + n := 0 + for _, e := range c.Entries { + if c.Class(e.ID) == ClassConfidential { + n++ + } + } + return n +} + +// SyncResult is what a banlist sync did. +type SyncResult struct { + // Sources counts the confidential sources projected. + Sources int `json:"sources"` + // Block is the private store's generated-block outcome. + Block banlist.GeneratedResult `json:"block"` +} + +// SyncBanlist projects the corpus's confidential entries into repoRoot's untracked +// private banlist (the itd-74 private layer), as the generated block the corpus +// owns. Hand-written entries outside the block survive; a declassified source's +// strings leave it on the next sync. A corpus whose classes disagree is refused +// before anything is written, so the block already there keeps banning. +func SyncBanlist(corpus, repoRoot string) (SyncResult, error) { + c, err := Load(corpus) + if err != nil { + return SyncResult{}, err + } + if err := c.requireConsistent(); err != nil { + return SyncResult{}, err + } + pats, err := c.Projection() + if err != nil { + return SyncResult{}, err + } + block, err := banlist.SyncGeneratedBlock(repoRoot, BlockOwner, pats) + if err != nil { + return SyncResult{}, err + } + return SyncResult{Sources: c.confidentialCount(), Block: block}, nil +} + +// Finding is one place a scanned text names a confidential source: the source's +// key, which of its strings matched (title, alias-N, author-N), and where. It +// carries no matched text, so a report is safe to relay. +type Finding struct { + Source string `json:"source"` + Field string `json:"field"` + Line int `json:"line"` + Offset int `json:"offset"` +} + +// CiteReport is a cite-check's outcome. +type CiteReport struct { + // Sources counts the confidential sources checked against. + Sources int `json:"sources"` + Findings []Finding `json:"findings"` +} + +// Clean reports whether the text named no confidential source. +func (r CiteReport) Clean() bool { return len(r.Findings) == 0 } + +// CiteCheck scans text for every confidential source's projected strings through +// the private layer's matcher — the engine the pre-commit guard runs — and reports +// offenders by key, field and position only. +func CiteCheck(corpus string, text []byte) (CiteReport, error) { + c, err := Load(corpus) + if err != nil { + return CiteReport{}, err + } + if err := c.requireConsistent(); err != nil { + return CiteReport{}, err + } + pats, err := c.Projection() + if err != nil { + return CiteReport{}, err + } + hits, err := banlist.ScanText(pats, text) + if err != nil { + return CiteReport{}, err + } + rep := CiteReport{Sources: c.confidentialCount(), Findings: []Finding{}} + for _, h := range hits { + rest := strings.TrimPrefix(h.Key, BlockOwner+"/") + key, field, _ := strings.Cut(rest, "/") + rep.Findings = append(rep.Findings, Finding{Source: key, Field: field, Line: h.Line, Offset: h.Offset}) + } + return rep, nil +} diff --git a/internal/core/source/ledger.go b/internal/core/source/ledger.go new file mode 100644 index 000000000..e4fb587bb --- /dev/null +++ b/internal/core/source/ledger.go @@ -0,0 +1,252 @@ +package source + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// maxLedgerBytes caps a ledger read (trust boundary). +const maxLedgerBytes = 64 << 20 + +// Record is one ledger line. The first eight fields are the spec's; used_in traces +// the influence to the consuming documents; corrects and flips name, by 1-based line +// number, the earlier line a correction or a citation flip refers to. A line is +// never edited: a correction and a flip are both new lines. +type Record struct { + TS string `json:"ts"` + Repo string `json:"repo"` + DecisionRef string `json:"decision_ref"` + Claim string `json:"claim"` + SourceKey string `json:"source_key"` + Locator string `json:"locator"` + Influence string `json:"influence"` + UsedIn []string `json:"used_in,omitempty"` + CitedPublicly bool `json:"cited_publicly"` + Corrects int `json:"corrects,omitempty"` + Flips int `json:"flips,omitempty"` +} + +// AppendRequest records one influence. +type AppendRequest struct { + Corpus string + Repo string + DecisionRef string + Claim string + SourceKey string + Locator string + Influence string + UsedIn []string + // Corrects is the 1-based line this record corrects, or 0. + Corrects int + Now time.Time +} + +// AppendResult is a line written (or read), with its number. +type AppendResult struct { + Path string `json:"path"` + Line int `json:"line"` + Record Record `json:"record"` +} + +// ledgerRel is the corpus-relative ledger path for repo. +func ledgerRel(repo string) string { return path.Join(LedgerDir, repo+".jsonl") } + +// readLedger reads repo's ledger; an absent file is an empty ledger. +func readLedger(dir, repo string) ([]Record, error) { + data, err := fsutil.ReadGuarded(filepath.Join(dir, filepath.FromSlash(ledgerRel(repo))), maxLedgerBytes) + switch { + case os.IsNotExist(err): + return nil, nil + case err != nil: + return nil, fmt.Errorf("%w: %s cannot be read (oversize, or not a regular file)", ErrInvalidLedger, ledgerRel(repo)) + } + var out []Record + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 64<<10), maxLedgerBytes) + n := 0 + for sc.Scan() { + n++ + var r Record + if err := json.Unmarshal(sc.Bytes(), &r); err != nil { + return nil, fmt.Errorf("%w: line %d of %s is not a JSON record", ErrInvalidLedger, n, ledgerRel(repo)) + } + out = append(out, r) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("%w: %s: %v", ErrInvalidLedger, ledgerRel(repo), err) + } + return out, nil +} + +// List returns repo's ledger, numbered from 1. An absent ledger is empty. +func List(corpus, repo string) ([]AppendResult, error) { + if _, err := Load(corpus); err != nil { + return nil, err + } + if !repoRe.MatchString(repo) { + return nil, fmt.Errorf("%w: repository handle %q is not a plain name", ErrInvalidLedger, repo) + } + recs, err := readLedger(corpus, repo) + if err != nil { + return nil, err + } + out := make([]AppendResult, len(recs)) + for i, r := range recs { + out[i] = AppendResult{Path: ledgerRel(repo), Line: i + 1, Record: r} + } + return out, nil +} + +// stamp is a ledger timestamp: UTC, RFC 3339, to the second. +func stamp(now time.Time) string { + if now.IsZero() { + now = time.Now() + } + return now.UTC().Format(time.RFC3339) +} + +// Append adds one influence record to repo's ledger and commits it. cited_publicly +// is always false here: exercising the right to cite is Flip's, and only Flip's. +func Append(req AppendRequest) (AppendResult, error) { + c, err := Load(req.Corpus) + if err != nil { + return AppendResult{}, err + } + bad := func(format string, a ...any) error { + return fmt.Errorf("%w: "+format, append([]any{ErrInvalidLedger}, a...)...) + } + switch { + case !repoRe.MatchString(req.Repo): + return AppendResult{}, bad("repository handle %q is not a plain name", req.Repo) + case !oneLine(req.DecisionRef): + return AppendResult{}, bad("the decision reference is empty or spans lines") + case strings.TrimSpace(req.Claim) == "": + return AppendResult{}, bad("the claim is empty") + case !slices.Contains(Influences, req.Influence): + return AppendResult{}, bad("influence %q is not one of %s", req.Influence, strings.Join(Influences, ", ")) + case req.Locator != "" && strings.ContainsAny(req.Locator, "\r\n"): + return AppendResult{}, bad("the locator spans lines") + case req.Corrects < 0: + return AppendResult{}, bad("corrects must name a line number") + } + for i, u := range req.UsedIn { + if !oneLine(u) { + return AppendResult{}, bad("used-in path %d is empty or spans lines", i+1) + } + } + if _, ok := c.Lookup(req.SourceKey); !ok { + return AppendResult{}, fmt.Errorf("%w: %q", ErrUnknownSource, req.SourceKey) + } + rec := Record{TS: stamp(req.Now), Repo: req.Repo, DecisionRef: strings.TrimSpace(req.DecisionRef), + Claim: strings.TrimSpace(req.Claim), SourceKey: req.SourceKey, Locator: req.Locator, + Influence: req.Influence, UsedIn: req.UsedIn, Corrects: req.Corrects} + return appendRecord(c.Dir, req.Repo, func(existing []Record) (Record, error) { + if req.Corrects > len(existing) { + return Record{}, bad("corrects names line %d, and the ledger has %d", req.Corrects, len(existing)) + } + return rec, nil + }) +} + +// Flip exercises the right to cite for one ledger line (adr-41 gate 2), and only +// after gate 1 grants it: the line's source must sit in public/ and carry +// permission_status citable. A refusal names the failing gate and appends nothing. +// A successful flip is itself a new line — the original with cited_publicly true +// and flips naming it — so the ledger keeps who cited what, and when. +// +// A person flips a line; an agent never does. The binary cannot tell the two apart, +// so the command pages carry that rule. +func Flip(corpus, repo string, line int, now time.Time) (AppendResult, error) { + c, err := Load(corpus) + if err != nil { + return AppendResult{}, err + } + if !repoRe.MatchString(repo) { + return AppendResult{}, fmt.Errorf("%w: repository handle %q is not a plain name", ErrInvalidLedger, repo) + } + return appendRecord(c.Dir, repo, func(existing []Record) (Record, error) { + if line < 1 || line > len(existing) { + return Record{}, fmt.Errorf("%w: the ledger has no line %d", ErrInvalidLedger, line) + } + orig := existing[line-1] + if orig.Flips != 0 { + return Record{}, fmt.Errorf("%w: line %d is itself a flip of line %d", ErrCitationRefused, line, orig.Flips) + } + for i, r := range existing { + if r.Flips == line { + return Record{}, fmt.Errorf("%w: line %d was already flipped, at line %d", ErrCitationRefused, line, i+1) + } + } + e, ok := c.Lookup(orig.SourceKey) + if !ok { + return Record{}, fmt.Errorf("%w: gate 1 — source %q is no longer in the corpus", ErrCitationRefused, orig.SourceKey) + } + if e.Custom.PermissionStatus != PermissionCitable { + return Record{}, fmt.Errorf("%w: gate 1 — source %q has permission_status %q, and only %q grants the right to cite", + ErrCitationRefused, orig.SourceKey, e.Custom.PermissionStatus, PermissionCitable) + } + if class := c.Class(orig.SourceKey); class != ClassPublic || e.Custom.Confidential { + return Record{}, fmt.Errorf("%w: gate 1 — source %q is confidential (its folder is not under public/); declassify it when it is published", + ErrCitationRefused, orig.SourceKey) + } + flip := orig + flip.TS = stamp(now) + flip.CitedPublicly = true + flip.Corrects = 0 + flip.Flips = line + return flip, nil + }) +} + +// appendRecord appends the record build returns — build sees the ledger as it +// stands, under the corpus lock, and may refuse — then commits it. +func appendRecord(dir, repo string, build func(existing []Record) (Record, error)) (AppendResult, error) { + var res AppendResult + err := withLock(dir, func() error { + existing, err := readLedger(dir, repo) + if err != nil { + return err + } + rec, err := build(existing) + if err != nil { + return err + } + b, err := json.Marshal(rec) + if err != nil { + return err + } + root, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer root.Close() + if err := root.MkdirAll(LedgerDir, 0o700); err != nil { + return fmt.Errorf("%w: cannot create %s/: %v", ErrCorpusInvalid, LedgerDir, err) + } + rel := ledgerRel(repo) + if err := fsutil.AppendLineIn(root, rel, b, 0o600); err != nil { + return fmt.Errorf("%w: appending to %s failed: %v", ErrInvalidLedger, rel, err) + } + line := len(existing) + 1 + msg := fmt.Sprintf("ledger(%s): line %d — %s", repo, line, rec.SourceKey) + if rec.Flips != 0 { + msg = fmt.Sprintf("ledger(%s): line %d flips line %d to cited_publicly — %s", repo, line, rec.Flips, rec.SourceKey) + } + if err := commit(dir, msg, rel); err != nil { + return err + } + res = AppendResult{Path: rel, Line: line, Record: rec} + return nil + }) + return res, err +} diff --git a/internal/core/source/source.go b/internal/core/source/source.go new file mode 100644 index 000000000..6b23b457a --- /dev/null +++ b/internal/core/source/source.go @@ -0,0 +1,449 @@ +// Package source is the personal sources corpus and its provenance ledger (itd-76, +// spc-31): a local-only store of documents the agent may consult, a CSL-JSON +// bibliography describing them, and one append-only influence ledger per consuming +// repository. It never prints and never exits — front doors under +// internal/surface/* format its results. +// +// The trust boundary it enforces is adr-41 / brief invariant 9, cited rather than +// restated: documents and ledgers never leave the user tier, and a public citation +// needs both the source's permission_status and a human-flipped ledger line. +// +// Layout of a corpus directory (the user-level home's `sources/`, by default +// ~/.abcd/sources): +// +// sources.json CSL-JSON array; each entry's `custom` block carries +// confidential, permission_status, keywords, aliases, +// ban_authors and file +// confidential/<key>/ original.<ext>, text.md, and derived artefacts +// public/<key>/ the same shape for a freely citable source +// ledger/<repo>.jsonl append-only influence records for one repository +// +// FOLDER LOCATION IS THE CLASSIFICATION. `custom.confidential` mirrors it, and a +// corpus where the two disagree is refused wholesale by every step that derives a +// ban from it (the safe direction: the block already written keeps banning). The +// corpus is itself a git repository with no remote; its history is the +// tamper-evidence layer, and every write here is committed. +// +// Matching is not this package's. The projection of a confidential entry into +// patterns and every scan run through banlist.PhrasePattern and banlist.ScanText, +// the private name layer's one matcher, so the pre-commit guard and cite-check +// cannot disagree about what a confidential source is called. +package source + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// Classes. The folder a source sits in is one of these, and nothing else decides it. +const ( + ClassConfidential = "confidential" + ClassPublic = "public" +) + +// Permission statuses. PermissionCitable is the ONLY value that grants the right to +// cite (adr-41 gate 1); every other value, including one this vocabulary does not +// know, withholds it. +const ( + PermissionCitable = "citable" + PermissionNoPublicCitation = "no-public-citation" + PermissionInternal = "internal-never-cite" + PermissionAIGenerated = "ai-generated-never-cite" + PermissionAskAuthor = "ask-author" +) + +// Permissions is the closed vocabulary `add` and `declassify` accept. +var Permissions = []string{PermissionCitable, PermissionNoPublicCitation, PermissionInternal, PermissionAIGenerated, PermissionAskAuthor} + +// Influences is the closed vocabulary of a ledger line's influence. +var Influences = []string{"supports", "contradicts", "method", "background"} + +// File and directory names inside a corpus. +const ( + SourcesFile = "sources.json" + LedgerDir = "ledger" + TextFile = "text.md" + readmeFile = "README.md" + lockFile = "abcd-source.lock" +) + +// maxSourcesBytes caps the bibliography read (trust boundary). +const maxSourcesBytes = 32 << 20 + +// lockTimeout bounds the wait for the corpus lock. +const lockTimeout = 5 * time.Second + +// Sentinel errors. No message carries a confidential title, alias or author: a +// refusal is output like any other, and keys are the only handle output names. +var ( + // ErrNoCorpus reports that the corpus directory does not exist. Every step that + // needs a corpus returns it, and a front door turns it into one loud line. + ErrNoCorpus = errors.New("sources corpus is absent") + // ErrCorpusInvalid reports a corpus directory that exists but cannot be used. + ErrCorpusInvalid = errors.New("sources corpus is not usable") + // ErrInvalidEntry rejects a source entry. + ErrInvalidEntry = errors.New("invalid source entry") + // ErrIdentifyingKey rejects a confidential entry whose key names it: the key is + // the one handle every refusal and every scan prints. + ErrIdentifyingKey = errors.New("a confidential source's key would name it") + // ErrDuplicateSource rejects a key the corpus already carries. + ErrDuplicateSource = errors.New("source key already exists") + // ErrUnknownSource reports a key the corpus does not carry. + ErrUnknownSource = errors.New("unknown source key") + // ErrClassMismatch reports a corpus whose folders and entries disagree. + ErrClassMismatch = errors.New("the corpus's classes disagree") + // ErrInvalidLedger rejects a ledger record, or reports a ledger that does not read. + ErrInvalidLedger = errors.New("invalid ledger record") + // ErrCitationRefused is the two-gate refusal of a cited_publicly flip. + ErrCitationRefused = errors.New("public citation refused") +) + +// keyRe is a source key: lowercase ASCII, path-safe, and inside the banlist key +// charset so it can head a generated entry's key. +var keyRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +// repoRe is a ledger's repository handle. +var repoRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +// Name is a CSL name: family and given, or one literal. +type Name struct { + Family string `json:"family,omitempty"` + Given string `json:"given,omitempty"` + Literal string `json:"literal,omitempty"` +} + +// Custom is the entry's abcd block. +type Custom struct { + Confidential bool `json:"confidential"` + PermissionStatus string `json:"permission_status"` + Keywords []string `json:"keywords,omitempty"` + Aliases []string `json:"aliases,omitempty"` + BanAuthors bool `json:"ban_authors,omitempty"` + File string `json:"file,omitempty"` +} + +// Date is a CSL date. +type Date struct { + DateParts [][]int `json:"date-parts"` +} + +// Entry is one CSL-JSON item as this package reads it. Fields it does not name are +// kept byte-for-byte when the bibliography is rewritten. +type Entry struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Author []Name `json:"author,omitempty"` + Issued *Date `json:"issued,omitempty"` + ContainerTitle string `json:"container-title,omitempty"` + URL string `json:"URL,omitempty"` + Custom Custom `json:"custom"` +} + +// Corpus is a loaded corpus: its entries, the raw bytes of each (so a rewrite +// preserves what this package does not model), and the folder each key sits in. +type Corpus struct { + Dir string + Entries []Entry + raw []json.RawMessage + // folders maps a key to the classes it has a folder under. + folders map[string][]string +} + +// Problem is one inconsistency, named by key only. +type Problem struct { + Key string `json:"key"` + Reason string `json:"reason"` +} + +// DefaultDir is the corpus's default location: the user-level home's `sources/`. +// Relocating the home is itd-77's concern; a caller may pass any directory. +func DefaultDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", fmt.Errorf("cannot resolve the home directory for the default corpus: %v", err) + } + return filepath.Join(home, ".abcd", "sources"), nil +} + +// present reports whether dir exists, and refuses one that is not a real directory. +func present(dir string) error { + fi, err := os.Lstat(dir) + switch { + case os.IsNotExist(err): + return fmt.Errorf("%w: there is no corpus at the configured location", ErrNoCorpus) + case err != nil: + return fmt.Errorf("%w: the corpus location cannot be read: %v", ErrCorpusInvalid, err) + case fi.Mode()&os.ModeSymlink != 0: + return fmt.Errorf("%w: the corpus location is a symlink; point at the real directory", ErrCorpusInvalid) + case !fi.IsDir(): + return fmt.Errorf("%w: the corpus location is not a directory", ErrCorpusInvalid) + } + return nil +} + +// Load reads a corpus. An absent directory is ErrNoCorpus; a directory without a +// bibliography or a git repository is ErrCorpusInvalid. +func Load(dir string) (*Corpus, error) { + if err := present(dir); err != nil { + return nil, err + } + if fi, err := os.Lstat(filepath.Join(dir, ".git")); err != nil || !fi.IsDir() { + return nil, fmt.Errorf("%w: the corpus directory is not a git repository (run `abcd source init` on an empty location)", ErrCorpusInvalid) + } + data, err := fsutil.ReadGuarded(filepath.Join(dir, SourcesFile), maxSourcesBytes) + if err != nil { + return nil, fmt.Errorf("%w: %s cannot be read (absent, oversize, or not a regular file)", ErrCorpusInvalid, SourcesFile) + } + c := &Corpus{Dir: dir, folders: map[string][]string{}} + if err := json.Unmarshal(data, &c.raw); err != nil { + return nil, fmt.Errorf("%w: %s is not a CSL-JSON array", ErrCorpusInvalid, SourcesFile) + } + for i, r := range c.raw { + var e Entry + if err := json.Unmarshal(r, &e); err != nil { + return nil, fmt.Errorf("%w: entry %d of %s does not read as CSL-JSON", ErrCorpusInvalid, i+1, SourcesFile) + } + c.Entries = append(c.Entries, e) + } + for _, class := range []string{ClassConfidential, ClassPublic} { + des, err := os.ReadDir(filepath.Join(dir, class)) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s/ cannot be listed", ErrCorpusInvalid, class) + } + for _, de := range des { + if de.IsDir() { + c.folders[de.Name()] = append(c.folders[de.Name()], class) + } + } + } + return c, nil +} + +// Lookup returns the entry for key. +func (c *Corpus) Lookup(key string) (Entry, bool) { + for _, e := range c.Entries { + if e.ID == key { + return e, true + } + } + return Entry{}, false +} + +// Class is the class a key's folder declares, or "" when it has none or two. +func (c *Corpus) Class(key string) string { + if f := c.folders[key]; len(f) == 1 { + return f[0] + } + return "" +} + +// Problems lists every inconsistency between entries and folders, by key only. +func (c *Corpus) Problems() []Problem { + var out []Problem + seen := map[string]bool{} + for _, e := range c.Entries { + switch { + case !keyRe.MatchString(e.ID): + out = append(out, Problem{Key: "(entry with an invalid id)", Reason: "the id is not a valid source key"}) + continue + case seen[e.ID]: + out = append(out, Problem{Key: e.ID, Reason: "the key appears twice in " + SourcesFile}) + continue + } + seen[e.ID] = true + f := c.folders[e.ID] + switch len(f) { + case 0: + out = append(out, Problem{Key: e.ID, Reason: "no folder under confidential/ or public/"}) + continue + case 2: + out = append(out, Problem{Key: e.ID, Reason: "a folder under both confidential/ and public/"}) + continue + } + folderConf := f[0] == ClassConfidential + if folderConf != e.Custom.Confidential { + out = append(out, Problem{Key: e.ID, Reason: fmt.Sprintf("the folder is under %s/ but the entry says confidential: %v", f[0], e.Custom.Confidential)}) + continue + } + if folderConf && e.Custom.PermissionStatus == PermissionCitable { + out = append(out, Problem{Key: e.ID, Reason: "a confidential source cannot be citable; declassify it instead"}) + } + } + var orphans []string + for k := range c.folders { + if !seen[k] && keyRe.MatchString(k) { + orphans = append(orphans, k) + } + } + sort.Strings(orphans) + for _, k := range orphans { + out = append(out, Problem{Key: k, Reason: "a folder with no entry in " + SourcesFile}) + } + return out +} + +// requireConsistent refuses a corpus with any problem, naming every key. +func (c *Corpus) requireConsistent() error { + probs := c.Problems() + if len(probs) == 0 { + return nil + } + parts := make([]string, len(probs)) + for i, p := range probs { + parts[i] = p.Key + " (" + p.Reason + ")" + } + return fmt.Errorf("%w: repair these entries first — %s; nothing was written, and the generated banlist block (if any) is left as it was", + ErrClassMismatch, strings.Join(parts, "; ")) +} + +// StatusReport is the corpus as the bare verb shows it. It holds counts and keys' +// numbers only — no title, alias or author. +type StatusReport struct { + Present bool `json:"present"` + Dir string `json:"dir"` + Confidential int `json:"confidential"` + Public int `json:"public"` + Problems []Problem `json:"problems"` + Ledgers []LedgerCount `json:"ledgers"` + Remotes int `json:"remotes"` +} + +// LedgerCount is one ledger file's size. +type LedgerCount struct { + Repo string `json:"repo"` + Lines int `json:"lines"` +} + +// Status reports the corpus at dir. An absent corpus is Present false and no error. +func Status(dir string) (StatusReport, error) { + st := StatusReport{Dir: dir, Problems: []Problem{}, Ledgers: []LedgerCount{}} + c, err := Load(dir) + switch { + case errors.Is(err, ErrNoCorpus): + return st, nil + case err != nil: + return st, err + } + st.Present = true + for _, e := range c.Entries { + switch c.Class(e.ID) { + case ClassConfidential: + st.Confidential++ + case ClassPublic: + st.Public++ + } + } + st.Problems = append(st.Problems, c.Problems()...) + des, _ := os.ReadDir(filepath.Join(dir, LedgerDir)) + for _, de := range des { + repo, ok := strings.CutSuffix(de.Name(), ".jsonl") + if !ok || de.IsDir() || !repoRe.MatchString(repo) { + continue + } + lines, _ := readLedger(dir, repo) + st.Ledgers = append(st.Ledgers, LedgerCount{Repo: repo, Lines: len(lines)}) + } + if out, err := corpusGit(dir, "remote"); err == nil && strings.TrimSpace(out) != "" { + st.Remotes = len(strings.Fields(out)) + } + return st, nil +} + +// readme is the manual Init lays in a new corpus. +const readme = `# Sources corpus + +Local-only. This directory is a git repository with no remote, and nothing in it +is ever committed to a project repository (adr-41). + +- sources.json CSL-JSON bibliography; each entry's "custom" block carries + confidential, permission_status, keywords, aliases, + ban_authors and file +- confidential/<key>/ original.<ext>, text.md, and anything derived from them +- public/<key>/ the same shape for a freely citable source +- ledger/<repo>.jsonl append-only influence records, one file per repository + +The folder a source sits in IS its classification. Maintain the corpus with +` + "`abcd source`" + ` (add, declassify, ledger, sync-banlist, cite-check); a hand +edit that leaves an entry and its folder disagreeing is refused by every step that +derives a ban from the corpus until it is repaired. + +Durability is yours: back this directory up, and keep an offline +` + "`git bundle`" + ` snapshot; abcd cannot do either for you. +` + +// Init creates a corpus at dir: the directory (0700), a no-remote git repository, +// an empty bibliography, and the manual, in one commit. An existing corpus is +// refused, as is a location inside another repository's working tree, where one +// `git add -A` would carry documents into it (adr-41). +func Init(dir string) (StatusReport, error) { + if !filepath.IsAbs(dir) { + return StatusReport{}, fmt.Errorf("%w: the corpus location must be an absolute path", ErrCorpusInvalid) + } + switch err := present(dir); { + case err == nil: + if _, lerr := Load(dir); lerr == nil { + return StatusReport{}, fmt.Errorf("%w: a corpus already exists at the configured location", ErrCorpusInvalid) + } + if has, _ := fsutil.DirHasEntries(dir); has { + return StatusReport{}, fmt.Errorf("%w: the location exists and is not empty; init only creates a corpus", ErrCorpusInvalid) + } + case !errors.Is(err, ErrNoCorpus): + return StatusReport{}, err + } + if outer := gitutil.RepoShapedRoot(filepath.Dir(dir)); outer != "" { + return StatusReport{}, fmt.Errorf("%w: the location is inside another git working tree, where a corpus is one `git add -A` from being committed; choose a location outside every repository", ErrCorpusInvalid) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return StatusReport{}, fmt.Errorf("%w: cannot create the corpus directory: %v", ErrCorpusInvalid, err) + } + if _, err := corpusGit(dir, "init", "-q"); err != nil { + return StatusReport{}, fmt.Errorf("%w: git init failed: %v", ErrCorpusInvalid, err) + } + if err := fsutil.WriteFileAtomic(filepath.Join(dir, SourcesFile), []byte("[]\n"), 0o600); err != nil { + return StatusReport{}, err + } + if err := fsutil.WriteFileAtomic(filepath.Join(dir, readmeFile), []byte(readme), 0o600); err != nil { + return StatusReport{}, err + } + if err := commit(dir, "source: init corpus", SourcesFile, readmeFile); err != nil { + return StatusReport{}, err + } + return Status(dir) +} + +// withLock serialises writers of one corpus. The lock lives inside the corpus's +// git directory, which nothing tracks. +func withLock(dir string, fn func() error) error { + err := fsutil.WithFileLock(filepath.Join(dir, ".git", lockFile), lockTimeout, fn) + if errors.Is(err, fsutil.ErrLockContention) { + return fmt.Errorf("%w: another process holds the corpus lock", ErrCorpusInvalid) + } + return err +} + +// writeSources rewrites the bibliography from raw entries, atomically. +func writeSources(dir string, raw []json.RawMessage) error { + if raw == nil { + raw = []json.RawMessage{} + } + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + return fsutil.WriteFileAtomic(filepath.Join(dir, SourcesFile), append(out, '\n'), 0o600) +} + +// validPermission reports membership of the closed vocabulary. +func validPermission(p string) bool { return slices.Contains(Permissions, p) } diff --git a/internal/core/source/source_test.go b/internal/core/source/source_test.go new file mode 100644 index 000000000..bb1bdf3b6 --- /dev/null +++ b/internal/core/source/source_test.go @@ -0,0 +1,563 @@ +package source + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/banlist" + "github.com/intentdriven/abcd/internal/gittest" +) + +// The fixtures below are invented: no real source, author or project is named. +const ( + confTitle = "Quiet Harbour Working Notes" + confAlias = "harbourwatch" + confAuthor = "Okonkwo" + pubTitle = "An Open Survey of Ledger Formats" +) + +var fixedNow = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + +// hermetic isolates every test from the real home, the real corpus and the real +// git identity: HOME is a temp dir (so the default corpus is a temp path), and the +// corpus commits carry a fixture identity. +func hermetic(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git unavailable") + } + home := t.TempDir() + t.Setenv("HOME", home) + gittest.Env(t) + t.Setenv("GIT_AUTHOR_NAME", "Alice Example") + t.Setenv("GIT_AUTHOR_EMAIL", "alice@example.com") + t.Setenv("GIT_COMMITTER_NAME", "Alice Example") + t.Setenv("GIT_COMMITTER_EMAIL", "alice@example.com") + return home +} + +// newCorpus initialises a corpus under the temp home and returns its path. +func newCorpus(t *testing.T) string { + t.Helper() + home := hermetic(t) + dir := filepath.Join(home, ".abcd", "sources") + if _, err := Init(dir); err != nil { + t.Fatalf("Init: %v", err) + } + return dir +} + +func writeFile(t *testing.T, dir, name, body string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func addConfidential(t *testing.T, corpus string, banAuthors bool) { + t.Helper() + src := t.TempDir() + _, err := Add(AddRequest{ + Corpus: corpus, Key: "conf2026a", Title: confTitle, Type: "report", Class: ClassConfidential, + Authors: []Name{{Family: confAuthor, Given: "Adaeze"}}, + Keywords: []string{"harbours", "ledgers"}, Aliases: []string{confAlias}, BanAuthors: banAuthors, + Original: writeFile(t, src, "notes.pdf", "%PDF-1.4 binary-ish"), + Text: writeFile(t, src, "notes.txt", "extracted body of the notes\n"), + }) + if err != nil { + t.Fatalf("Add confidential: %v", err) + } +} + +func addPublic(t *testing.T, corpus string) { + t.Helper() + src := t.TempDir() + _, err := Add(AddRequest{ + Corpus: corpus, Key: "survey2026ledger", Title: pubTitle, Type: "article-journal", Class: ClassPublic, + Original: writeFile(t, src, "survey.md", "# survey\nbody\n"), + }) + if err != nil { + t.Fatalf("Add public: %v", err) + } +} + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + +func readEntries(t *testing.T, corpus string) []map[string]any { + t.Helper() + b, err := os.ReadFile(filepath.Join(corpus, "sources.json")) + if err != nil { + t.Fatal(err) + } + var out []map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("sources.json is not a CSL-JSON array: %v", err) + } + return out +} + +// TestAddConfidentialLandsUnderItsClassFolder is AC1: the entry (with its custom +// block) lands in sources.json, the document and its extracted text land under +// confidential/<key>/, nothing lands under public/, and the corpus commits it. +func TestAddConfidentialLandsUnderItsClassFolder(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, false) + + entries := readEntries(t, corpus) + if len(entries) != 1 || entries[0]["id"] != "conf2026a" || entries[0]["title"] != confTitle { + t.Fatalf("entries = %v", entries) + } + custom, _ := entries[0]["custom"].(map[string]any) + if custom["confidential"] != true || custom["permission_status"] != "no-public-citation" { + t.Fatalf("custom block = %v", custom) + } + if _, ok := custom["keywords"]; !ok { + t.Error("custom block has no keywords") + } + if _, ok := custom["aliases"]; !ok { + t.Error("custom block has no aliases") + } + folder := filepath.Join(corpus, "confidential", "conf2026a") + if b, err := os.ReadFile(filepath.Join(folder, "original.pdf")); err != nil || !bytes.HasPrefix(b, []byte("%PDF")) { + t.Fatalf("original not stored: %v", err) + } + text, err := os.ReadFile(filepath.Join(folder, "text.md")) + if err != nil || !strings.Contains(string(text), "extracted body") || !strings.Contains(string(text), "key: conf2026a") { + t.Fatalf("text.md = %q %v", text, err) + } + if _, err := os.Stat(filepath.Join(corpus, "public", "conf2026a")); !os.IsNotExist(err) { + t.Fatal("a confidential source landed under public/") + } + if st := git(t, corpus, "status", "--porcelain"); st != "" { + t.Fatalf("corpus not committed:\n%s", st) + } + if log := git(t, corpus, "log", "--oneline"); !strings.Contains(log, "conf2026a") { + t.Fatalf("no commit names the key:\n%s", log) + } +} + +// TestAddRefusals: every refusal writes nothing, and none quotes a confidential +// title or alias. +func TestAddRefusals(t *testing.T) { + corpus := newCorpus(t) + src := t.TempDir() + pdf := writeFile(t, src, "x.pdf", "%PDF") + base := AddRequest{Corpus: corpus, Key: "conf2026b", Title: confTitle, Class: ClassConfidential, Original: pdf, + Text: writeFile(t, src, "x.txt", "t")} + for _, tc := range []struct { + name string + mut func(r *AddRequest) + want error + }{ + {"no class", func(r *AddRequest) { r.Class = "" }, ErrInvalidEntry}, + {"confidential yet citable", func(r *AddRequest) { r.Permission = PermissionCitable }, ErrInvalidEntry}, + {"unknown permission", func(r *AddRequest) { r.Permission = "whatever" }, ErrInvalidEntry}, + {"key names an alias", func(r *AddRequest) { r.Key = "harbourwatch2026"; r.Aliases = []string{confAlias} }, ErrIdentifyingKey}, + {"key names the author", func(r *AddRequest) { r.Key = "okonkwo2026"; r.Authors = []Name{{Family: confAuthor}} }, ErrIdentifyingKey}, + {"bad key", func(r *AddRequest) { r.Key = "../x" }, ErrInvalidEntry}, + {"binary original with no text", func(r *AddRequest) { r.Text = "" }, ErrInvalidEntry}, + {"fragment alias", func(r *AddRequest) { r.Aliases = []string{"hb"} }, ErrInvalidEntry}, + {"empty title", func(r *AddRequest) { r.Title = " " }, ErrInvalidEntry}, + } { + t.Run(tc.name, func(t *testing.T) { + r := base + tc.mut(&r) + _, err := Add(r) + if !errors.Is(err, tc.want) { + t.Fatalf("err = %v, want %v", err, tc.want) + } + for _, leak := range []string{confTitle, confAlias} { + if strings.Contains(err.Error(), leak) { + t.Fatalf("refusal quotes %q: %v", leak, err) + } + } + if len(readEntries(t, corpus)) != 0 { + t.Fatal("a refused add wrote an entry") + } + }) + } + addConfidential(t, corpus, false) + r := base + r.Key = "conf2026a" + if _, err := Add(r); !errors.Is(err, ErrDuplicateSource) { + t.Fatalf("duplicate key: %v", err) + } +} + +// TestLedgerAppendsAndNeverEdits is AC2: each record is a new line carrying the +// spec's fields with cited_publicly false, and a correction is another new line — +// the earlier bytes are untouched. +func TestLedgerAppendsAndNeverEdits(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, false) + req := AppendRequest{Corpus: corpus, Repo: "0123456789ab", DecisionRef: "adr-41", Claim: "the ledger is append-only", + SourceKey: "conf2026a", Locator: "§2", Influence: "supports"} + first, err := Append(req) + if err != nil { + t.Fatal(err) + } + if first.Line != 1 || first.Path != "ledger/0123456789ab.jsonl" { + t.Fatalf("first = %+v", first) + } + path := filepath.Join(corpus, "ledger", "0123456789ab.jsonl") + before, _ := os.ReadFile(path) + + req.Claim = "the ledger is append-only, corrected" + req.Corrects = 1 + second, err := Append(req) + if err != nil || second.Line != 2 { + t.Fatalf("second = %+v %v", second, err) + } + after, _ := os.ReadFile(path) + if !bytes.HasPrefix(after, before) { + t.Fatal("a correction rewrote an earlier line") + } + lines := strings.Split(strings.TrimRight(string(after), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("ledger has %d lines", len(lines)) + } + var rec map[string]any + if err := json.Unmarshal([]byte(lines[0]), &rec); err != nil { + t.Fatal(err) + } + for _, f := range []string{"ts", "repo", "decision_ref", "claim", "source_key", "locator", "influence", "cited_publicly"} { + if _, ok := rec[f]; !ok { + t.Errorf("ledger line lacks %q", f) + } + } + if rec["cited_publicly"] != false { + t.Errorf("cited_publicly = %v", rec["cited_publicly"]) + } + if st := git(t, corpus, "status", "--porcelain"); st != "" { + t.Fatalf("ledger append not committed:\n%s", st) + } + + for _, bad := range []func(r *AppendRequest){ + func(r *AppendRequest) { r.Influence = "vibes" }, + func(r *AppendRequest) { r.SourceKey = "nosuchkey" }, + func(r *AppendRequest) { r.Claim = "" }, + func(r *AppendRequest) { r.Corrects = 9 }, + func(r *AppendRequest) { r.Repo = "../x" }, + } { + r := req + bad(&r) + if _, err := Append(r); err == nil { + t.Errorf("a bad append was accepted: %+v", r) + } + } +} + +// TestFlipNeedsBothGates is AC5: a flip on a line whose source lacks permission is +// refused naming the failing gate and appends nothing; with permission present the +// flip succeeds as a NEW line, and a second flip of the same line is refused. +func TestFlipNeedsBothGates(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, false) + addPublic(t, corpus) + mk := func(key string) int { + res, err := Append(AppendRequest{Corpus: corpus, Repo: "0123456789ab", DecisionRef: "d", Claim: "c", SourceKey: key, Influence: "method"}) + if err != nil { + t.Fatal(err) + } + return res.Line + } + confLine := mk("conf2026a") + pubLine := mk("survey2026ledger") + + _, err := Flip(corpus, "0123456789ab", confLine, fixedNow) + if !errors.Is(err, ErrCitationRefused) || !strings.Contains(err.Error(), "permission_status") { + t.Fatalf("flip on an unpermitted source: %v", err) + } + lines, _ := List(corpus, "0123456789ab") + if len(lines) != 2 { + t.Fatalf("a refused flip appended: %d lines", len(lines)) + } + + res, err := Flip(corpus, "0123456789ab", pubLine, fixedNow) + if err != nil { + t.Fatal(err) + } + if res.Line != 3 || !res.Record.CitedPublicly || res.Record.Flips != pubLine { + t.Fatalf("flip result = %+v", res) + } + if _, err := Flip(corpus, "0123456789ab", pubLine, fixedNow); !errors.Is(err, ErrCitationRefused) { + t.Fatalf("second flip: %v", err) + } + if _, err := Flip(corpus, "0123456789ab", 3, fixedNow); !errors.Is(err, ErrCitationRefused) { + t.Fatalf("flipping a flip line: %v", err) + } +} + +// TestProjectionTitlesAliasesAlwaysAuthorsOnlyOnOptIn is AC3's projection half. +func TestProjectionTitlesAliasesAlwaysAuthorsOnlyOnOptIn(t *testing.T) { + for _, ban := range []bool{false, true} { + corpus := newCorpus(t) + addConfidential(t, corpus, ban) + addPublic(t, corpus) + c, err := Load(corpus) + if err != nil { + t.Fatal(err) + } + pats, err := c.Projection() + if err != nil { + t.Fatal(err) + } + keys := map[string]bool{} + for _, p := range pats { + keys[p.Key] = true + } + if !keys["sources/conf2026a/title"] || !keys["sources/conf2026a/alias-1"] { + t.Errorf("ban=%v: projection keys %v", ban, keys) + } + if keys["sources/conf2026a/author-1"] != ban { + t.Errorf("ban=%v: author key present=%v", ban, keys["sources/conf2026a/author-1"]) + } + for k := range keys { + if strings.Contains(k, "survey2026ledger") { + t.Errorf("a public source was projected: %s", k) + } + } + } +} + +// guardRepo is a throwaway repository with the committed pre-commit guard installed +// and the local tier gitignored, as a managed repo has it. +func guardRepo(t *testing.T) string { + t.Helper() + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Skip("not in a checkout: the committed guard cannot be found") + } + hook, err := os.ReadFile(filepath.Join(strings.TrimSpace(string(out)), ".githooks", "pre-commit")) + if err != nil { + t.Skipf("guard not found: %v", err) + } + repo := t.TempDir() + git(t, repo, "init", "-q") + git(t, repo, "config", "user.name", "Alice Example") + git(t, repo, "config", "user.email", "alice@example.com") + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte(".abcd/.work.local/\n"), 0o644); err != nil { + t.Fatal(err) + } + hooks := filepath.Join(repo, ".git", "hooks") + if err := os.MkdirAll(hooks, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hooks, "pre-commit"), hook, 0o755); err != nil { + t.Fatal(err) + } + return repo +} + +// TestSyncBanlistFeedsTheGuard is AC3 end to end: the sync writes the generated +// block into the repo's untracked private store, and the committed guard then +// refuses a commit carrying the confidential title (named by key only) while a +// commit naming the public source passes. +func TestSyncBanlistFeedsTheGuard(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, false) + addPublic(t, corpus) + repo := guardRepo(t) + + res, err := SyncBanlist(corpus, repo) + if err != nil { + t.Fatal(err) + } + if res.Sources != 1 || res.Block.Entries != 2 { + t.Fatalf("sync = %+v", res) + } + + if err := os.WriteFile(filepath.Join(repo, "ok.md"), []byte("we follow "+pubTitle+"\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, repo, "add", "ok.md", ".gitignore") + cmd := exec.Command("git", "-C", repo, "commit", "-q", "-m", "ok") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("a public citation was refused: %v\n%s", err, out) + } + + if err := os.WriteFile(filepath.Join(repo, "leak.md"), []byte("per the "+strings.ToLower(confTitle)+", we\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, repo, "add", "leak.md") + cmd = exec.Command("git", "-C", repo, "commit", "-q", "-m", "leak") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("the guard let a confidential title through:\n%s", out) + } + if !strings.Contains(string(out), "sources/conf2026a/title") { + t.Errorf("the refusal does not name the key:\n%s", out) + } + if strings.Contains(strings.ToLower(string(out)), "harbour") { + t.Errorf("the refusal leaks the title:\n%s", out) + } +} + +// TestCiteCheckReportsByKeyOnly is AC4: offending sources are reported by key and +// position, and nothing in the report — text or JSON — carries the matched string. +func TestCiteCheckReportsByKeyOnly(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, true) + addPublic(t, corpus) + text := "intro\nAs " + confTitle + " argues, and " + pubTitle + " agrees.\nAsk Adaeze " + confAuthor + ".\n" + rep, err := CiteCheck(corpus, []byte(text)) + if err != nil { + t.Fatal(err) + } + if rep.Clean() { + t.Fatal("cite-check called the text clean") + } + keys := map[string]bool{} + for _, f := range rep.Findings { + keys[f.Source] = true + } + if len(keys) != 1 || !keys["conf2026a"] { + t.Fatalf("findings name %v", keys) + } + blob, _ := json.Marshal(rep) + for _, leak := range []string{"Harbour", "harbour", confAuthor, "Adaeze", "Ledger Formats"} { + if strings.Contains(string(blob), leak) { + t.Fatalf("the report carries %q: %s", leak, blob) + } + } + clean, err := CiteCheck(corpus, []byte("nothing to see; "+pubTitle+"\n")) + if err != nil || !clean.Clean() { + t.Fatalf("clean text: %+v %v", clean, err) + } +} + +// TestDeclassifyDropsTheBanAndOpensTheFlip is AC7: the visible move to public/ is +// what the next sync reads, so the key's strings leave the block, and the source's +// ledger lines become flippable once declassification has set its permission. +func TestDeclassifyDropsTheBanAndOpensTheFlip(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, false) + repo := guardRepo(t) + line, err := Append(AppendRequest{Corpus: corpus, Repo: "0123456789ab", DecisionRef: "d", Claim: "c", SourceKey: "conf2026a", Influence: "background"}) + if err != nil { + t.Fatal(err) + } + if _, err := SyncBanlist(corpus, repo); err != nil { + t.Fatal(err) + } + store := filepath.Join(repo, ".abcd", ".work.local", "private-names.txt") + if b, _ := os.ReadFile(store); !strings.Contains(string(b), "sources/conf2026a/title") { + t.Fatal("block does not carry the confidential key before declassification") + } + + if _, err := Declassify(corpus, "conf2026a", ""); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(corpus, "public", "conf2026a", "text.md")); err != nil { + t.Fatalf("folder not moved to public/: %v", err) + } + if st := git(t, corpus, "status", "--porcelain"); st != "" { + t.Fatalf("declassification not committed:\n%s", st) + } + if _, err := SyncBanlist(corpus, repo); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(store); strings.Contains(string(b), "sources/conf2026a") { + t.Fatalf("the declassified key survived the refresh:\n%s", b) + } + if _, err := Flip(corpus, "0123456789ab", line.Line, fixedNow); err != nil { + t.Fatalf("flip after declassification: %v", err) + } +} + +// TestAMismatchedClassRefusesTheSync: a folder moved by hand without its entry is a +// corpus whose classes disagree. The sync writes nothing and names the key, so the +// existing block keeps banning — the safe direction. +func TestAMismatchedClassRefusesTheSync(t *testing.T) { + corpus := newCorpus(t) + addConfidential(t, corpus, false) + repo := guardRepo(t) + if _, err := SyncBanlist(corpus, repo); err != nil { + t.Fatal(err) + } + store := filepath.Join(repo, ".abcd", ".work.local", "private-names.txt") + before, _ := os.ReadFile(store) + if err := os.MkdirAll(filepath.Join(corpus, "public"), 0o700); err != nil { + t.Fatal(err) + } + git(t, corpus, "mv", "confidential/conf2026a", "public/conf2026a") + + _, err := SyncBanlist(corpus, repo) + if !errors.Is(err, ErrClassMismatch) || !strings.Contains(err.Error(), "conf2026a") { + t.Fatalf("sync over a mismatch: %v", err) + } + after, _ := os.ReadFile(store) + if !bytes.Equal(before, after) { + t.Fatal("a refused sync rewrote the store") + } + if _, err := CiteCheck(corpus, []byte("x")); !errors.Is(err, ErrClassMismatch) { + t.Fatalf("cite-check over a mismatch: %v", err) + } +} + +// TestNoCorpusIsNamedByEveryStep is AC6's core half: every corpus-dependent step +// reports the one sentinel a front door turns into its loud notice, and none of +// them creates the corpus. +func TestNoCorpusIsNamedByEveryStep(t *testing.T) { + home := hermetic(t) + dir := filepath.Join(home, ".abcd", "sources") + repo := t.TempDir() + steps := map[string]func() error{ + "add": func() error { + _, err := Add(AddRequest{Corpus: dir, Key: "k2026x", Title: "Some Title", Class: ClassPublic}) + return err + }, + "ledger": func() error { + _, err := Append(AppendRequest{Corpus: dir, Repo: "r", DecisionRef: "d", Claim: "c", SourceKey: "k", Influence: "method"}) + return err + }, + "flip": func() error { _, err := Flip(dir, "r", 1, fixedNow); return err }, + "sync": func() error { _, err := SyncBanlist(dir, repo); return err }, + "cite-check": func() error { _, err := CiteCheck(dir, []byte("x")); return err }, + "declassify": func() error { _, err := Declassify(dir, "k", ""); return err }, + "list ledger": func() error { _, err := List(dir, "r"); return err }, + } + for name, step := range steps { + if err := step(); !errors.Is(err, ErrNoCorpus) { + t.Errorf("%s: err = %v, want ErrNoCorpus", name, err) + } + } + st, err := Status(dir) + if err != nil || st.Present { + t.Fatalf("status over no corpus: %+v %v", st, err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("a step created the corpus") + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(banlist.PrivateRelPath))); !os.IsNotExist(err) { + t.Fatal("a sync with no corpus wrote the private store") + } +} + +// TestInitRefusesInsideAnotherRepository: the corpus never lives inside a working +// tree, where one `git add -A` would carry documents into a repository (adr-41). +func TestInitRefusesInsideAnotherRepository(t *testing.T) { + hermetic(t) + repo := t.TempDir() + git(t, repo, "init", "-q") + if _, err := Init(filepath.Join(repo, "corpus")); !errors.Is(err, ErrCorpusInvalid) { + t.Fatalf("init inside a repo: %v", err) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index ae5cc9bd4..ef89d36e8 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -359,6 +359,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newCaptureCommand(&asJSON)) root.AddCommand(newBanlistCommand(&asJSON)) + root.AddCommand(newSourceCommand(&asJSON)) root.AddCommand(newMemoryCommand(&asJSON)) root.AddCommand(newRulesCommand(&asJSON)) root.AddCommand(newHookCommand()) @@ -386,6 +387,7 @@ func NewRootCommand() *cobra.Command { // them the token may be a private pattern. Applied here rather than in the verb // so the ordering is explicit — the generic pass would otherwise overwrite it. applyBanlistFlagErrors(root) + applySourceFlagErrors(root) // Also after the generic tagging: the assemble verb's flag refusal names the // two operands the design admits, because the operand it most often refuses // is one it used to take (adr-2609021016286571). diff --git a/internal/surface/cli/source.go b/internal/surface/cli/source.go new file mode 100644 index 000000000..1c5ae6fe5 --- /dev/null +++ b/internal/surface/cli/source.go @@ -0,0 +1,549 @@ +package cli + +// source.go is the front door onto internal/core/source — the personal sources +// corpus and its provenance ledger (itd-76, spc-31). +// +// Exit codes: 0 done; 1 cite-check found a confidential source in the text; 2 a +// refusal (a bad operand, a failed gate, a corpus that disagrees with itself); +// 3 there is no corpus at the configured location — the distinct no-corpus code +// every verb but `init` returns, so a script can tell "nothing to check against" +// from "checked and clean". `sync-banlist --refresh` is the guard's mode: with no +// corpus it says so on one line and exits 0, because a commit on a machine that +// never made a corpus is not a failure. +// +// Nothing here prints a confidential title, alias or author. The core's refusals +// never carry one, and every report names sources by key. + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/core/source" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" + "github.com/intentdriven/abcd/internal/termsafe" + "github.com/spf13/cobra" +) + +// maxScanBytes caps a cite-check input (trust boundary). +const maxScanBytes = 64 << 20 + +// sourceCorpusDir resolves --corpus, or the default under the user-level home. +func sourceCorpusDir(flag string) (string, error) { + if flag != "" { + if !strings.HasPrefix(flag, "/") { + return "", &exitError{Code: 2, Msg: "abcd source: --corpus must be an absolute path"} + } + return flag, nil + } + dir, err := source.DefaultDir() + if err != nil { + return "", &exitError{Code: 2, Msg: "abcd source: " + err.Error()} + } + return dir, nil +} + +// sourceError maps a core error to the verb's exit code and one line. +func sourceError(verb, dir string, err error) error { + if errors.Is(err, source.ErrNoCorpus) { + return &exitError{Code: 3, Msg: fmt.Sprintf("abcd source %s: no sources corpus at %s — nothing read or written (create one with `abcd source init`)", + verb, fsutil.RedactHome(dir))} + } + return &exitError{Code: 2, Msg: "abcd source " + verb + ": " + termsafe.Sanitize(scrubPaths(err))} +} + +// newSourceCommand builds the `source` verb. +func newSourceCommand(asJSON *bool) *cobra.Command { + var corpusFlag string + cmd := &cobra.Command{ + Use: "source", + Short: "The personal sources corpus and its provenance ledger (bare renders its state, read-only)", + Long: "The personal sources corpus: documents you may consult, a CSL-JSON bibliography, and one\n" + + "append-only influence ledger per repository, in a local-only git repository with no\n" + + "remote (~/.abcd/sources by default; --corpus names another). The folder a source sits\n" + + "in — confidential/<key>/ or public/<key>/ — is its classification.\n\n" + + "Consult freely, cite deliberately: confidential entries are projected into this\n" + + "repository's untracked private banlist (sync-banlist), which the committed pre-commit\n" + + "guard refreshes and enforces; cite-check clears text before it leaves the machine; and\n" + + "a ledger line becomes a public citation only when the source permits it AND a person\n" + + "flips the line (adr-41). No output names a confidential source except by key.\n\n" + + "Bare `abcd source` is read-only. Exit 3 when there is no corpus, on every verb but init.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := sourceCorpusDir(corpusFlag) + if err != nil { + return err + } + st, err := source.Status(dir) + if err != nil { + return sourceError("status", dir, err) + } + if !st.Present { + return sourceError("status", dir, source.ErrNoCorpus) + } + st.Dir = fsutil.RedactHome(st.Dir) + return render(cmd.OutOrStdout(), *asJSON, st, func(w io.Writer) { renderSourceStatus(w, st) }) + }, + } + cmd.PersistentFlags().StringVar(&corpusFlag, "corpus", "", "the corpus directory (absolute; default ~/.abcd/sources)") + cmd.AddCommand(newSourceInitCommand(asJSON, &corpusFlag)) + cmd.AddCommand(newSourceAddCommand(asJSON, &corpusFlag)) + cmd.AddCommand(newSourceDeclassifyCommand(asJSON, &corpusFlag)) + cmd.AddCommand(newSourceLedgerCommand(asJSON, &corpusFlag)) + cmd.AddCommand(newSourceSyncBanlistCommand(asJSON, &corpusFlag)) + cmd.AddCommand(newSourceCiteCheckCommand(asJSON, &corpusFlag)) + return cmd +} + +func renderSourceStatus(w io.Writer, st source.StatusReport) { + fmt.Fprintf(w, "abcd source — corpus at %s\n", st.Dir) + fmt.Fprintf(w, " sources: %d confidential, %d public\n", st.Confidential, st.Public) + if len(st.Ledgers) == 0 { + fmt.Fprintln(w, " ledgers: none") + } + for _, l := range st.Ledgers { + fmt.Fprintf(w, " ledger: %s (%d line%s)\n", l.Repo, l.Lines, pluralS(l.Lines)) + } + if len(st.Problems) == 0 { + fmt.Fprintln(w, " problems: none") + } + for _, p := range st.Problems { + fmt.Fprintf(w, " problem: %s — %s\n", termsafe.Sanitize(p.Key), termsafe.Sanitize(p.Reason)) + } + if st.Remotes > 0 { + fmt.Fprintf(w, " WARNING: the corpus repository has %d remote(s); documents and ledgers never leave this machine (adr-41) — remove it\n", st.Remotes) + } +} + +func newSourceInitCommand(asJSON *bool, corpusFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Create an empty corpus: a no-remote git repository with an empty bibliography", + Long: "Create the corpus at its location (0700): a git repository with no remote, an empty\n" + + "sources.json and a README, in one commit. Refuses an existing corpus, a non-empty\n" + + "directory, and a location inside another repository's working tree.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := sourceCorpusDir(*corpusFlag) + if err != nil { + return err + } + st, err := source.Init(dir) + if err != nil { + return sourceError("init", dir, err) + } + st.Dir = fsutil.RedactHome(st.Dir) + return render(cmd.OutOrStdout(), *asJSON, st, func(w io.Writer) { + fmt.Fprintf(w, "abcd source init — corpus created at %s (a git repository with no remote)\n", st.Dir) + }) + }, + } +} + +// sourceMeta is --meta's JSON: the identifying fields, kept out of argv. +type sourceMeta struct { + Title string `json:"title"` + Aliases []string `json:"aliases"` + Authors []source.Name `json:"author"` + Keywords []string `json:"keywords"` +} + +// parseAuthor reads "Family, Given" as a CSL name, anything else as a literal. +func parseAuthor(s string) source.Name { + if fam, giv, ok := strings.Cut(s, ","); ok { + return source.Name{Family: strings.TrimSpace(fam), Given: strings.TrimSpace(giv)} + } + return source.Name{Literal: strings.TrimSpace(s)} +} + +func newSourceAddCommand(asJSON *bool, corpusFlag *string) *cobra.Command { + var ( + key, title, typ, perm, venue, url, text, meta string + authors, keywords, aliases []string + year int + confidential, public, banAuthors bool + ) + cmd := &cobra.Command{ + Use: "add [document]", + Short: "Register a source: its entry, the document and its text under its class folder", + Long: "Register a source: write its CSL-JSON entry (with the custom block), store the document\n" + + "as original.<ext> and its extracted text as text.md under confidential/<key>/ or\n" + + "public/<key>/, and commit the corpus. The class is declared here, once: exactly one\n" + + "of --confidential or --public is required. abcd converts nothing and fetches nothing:\n" + + "a Markdown or text document is its own text, any other needs --text, and a URL\n" + + "alone registers a metadata stub.\n\n" + + "A confidential entry's title, aliases and (under --ban-authors) authors become banned\n" + + "phrases, so each must hold at least three letters or digits, and its key must not\n" + + "contain any of them — the key is what every refusal and scan prints. Pass those\n" + + "strings with --meta FILE (or --meta - on stdin) to keep them out of argv and shell\n" + + "history.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := sourceCorpusDir(*corpusFlag) + if err != nil { + return err + } + class := "" + switch { + case confidential && public: + return &exitError{Code: 2, Msg: "abcd source add: --confidential and --public are exclusive"} + case confidential: + class = source.ClassConfidential + case public: + class = source.ClassPublic + } + req := source.AddRequest{Corpus: dir, Key: key, Title: title, Type: typ, Class: class, Permission: perm, + Year: year, Venue: venue, URL: url, Keywords: splitList(keywords), Aliases: aliases, BanAuthors: banAuthors, Text: text} + for _, a := range authors { + req.Authors = append(req.Authors, parseAuthor(a)) + } + if len(args) == 1 { + req.Original = args[0] + } + if meta != "" { + m, err := readSourceMeta(cmd, meta) + if err != nil { + return &exitError{Code: 2, Msg: "abcd source add: " + err.Error()} + } + if m.Title != "" { + req.Title = m.Title + } + req.Aliases = append(req.Aliases, m.Aliases...) + req.Authors = append(req.Authors, m.Authors...) + req.Keywords = append(req.Keywords, m.Keywords...) + } + res, err := source.Add(req) + if err != nil { + return sourceError("add", dir, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "abcd source add — %s registered %s (%s), committed in the corpus\n", res.Key, res.Class, res.Permission) + for _, f := range res.Files { + fmt.Fprintf(w, " %s\n", f) + } + if res.Class == source.ClassConfidential { + fmt.Fprintln(w, " next: abcd source sync-banlist, in every repository you work in") + } + }) + }, + } + f := cmd.Flags() + f.StringVar(&key, "key", "", "the source key: lowercase, opaque for a confidential source (e.g. conf2026a)") + f.StringVar(&title, "title", "", "the exact title (for a confidential source prefer --meta)") + f.StringVar(&typ, "type", "", "the CSL item type (default document)") + f.BoolVar(&confidential, "confidential", false, "file the source under confidential/ (exclusive with --public)") + f.BoolVar(&public, "public", false, "file the source under public/ (exclusive with --confidential)") + f.StringVar(&perm, "permission", "", "permission_status: "+enumHelp(source.Permissions)+" (default by class)") + f.StringArrayVar(&authors, "author", nil, `an author, "Family, Given" or a literal name (repeatable)`) + f.IntVar(&year, "year", 0, "the year of issue") + f.StringVar(&venue, "venue", "", "the container title (journal, site, publisher)") + f.StringVar(&url, "url", "", "the canonical URL (recorded, never fetched)") + f.StringArrayVar(&keywords, "keywords", nil, "retrieval keywords, comma-separated (repeatable)") + f.StringArrayVar(&aliases, "alias", nil, "another identifying name for a confidential source (repeatable)") + f.BoolVar(&banAuthors, "ban-authors", false, "also ban the authors' names (a confidential source whose authorship is itself identifying)") + f.StringVar(&text, "text", "", "the extracted text of a non-text document") + f.StringVar(&meta, "meta", "", `a JSON file (or - for stdin) with "title", "aliases", "author" and "keywords"`) + return cmd +} + +// splitList flattens comma-separated values. +func splitList(in []string) []string { + var out []string + for _, v := range in { + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + } + return out +} + +// readSourceMeta reads --meta from a file or stdin. +func readSourceMeta(cmd *cobra.Command, from string) (sourceMeta, error) { + var data []byte + var err error + if from == "-" { + data, err = io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20)) + } else { + data, err = fsutil.ReadGuarded(from, 1<<20) + } + if err != nil { + return sourceMeta{}, errors.New("--meta cannot be read (absent, oversize, or not a regular file)") + } + var m sourceMeta + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + return sourceMeta{}, errors.New(`--meta is not a JSON object of "title", "aliases", "author" and "keywords" (its content is withheld)`) + } + return m, nil +} + +func newSourceDeclassifyCommand(asJSON *bool, corpusFlag *string) *cobra.Command { + var perm string + cmd := &cobra.Command{ + Use: "declassify <key>", + Short: "Move a published confidential source to public/ — a visible, committed move", + Long: "Declassify a confidential source once it is published: `git mv` its folder from\n" + + "confidential/ to public/ and set the entry's confidential flag and permission_status\n" + + "(citable unless --permission says otherwise), in one corpus commit. The next\n" + + "sync-banlist drops its strings, and its ledger lines become flippable.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := sourceCorpusDir(*corpusFlag) + if err != nil { + return err + } + res, err := source.Declassify(dir, args[0], perm) + if err != nil { + return sourceError("declassify", dir, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "abcd source declassify — %s moved to %s (%s), committed in the corpus\n", res.Key, res.Folder, res.Permission) + fmt.Fprintln(w, " next: abcd source sync-banlist, in every repository you work in") + }) + }, + } + cmd.Flags().StringVar(&perm, "permission", "", "permission_status after the move: "+enumHelp(source.Permissions)+" (default citable)") + return cmd +} + +// ledgerRepo is the ledger's repository handle: --repo, or the first twelve hex +// digits of this checkout's root commit, which every worktree and clone of one +// repository shares whatever its directory is called. +func ledgerRepo(flag string) (string, error) { + if flag != "" { + return flag, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + root, err := gitutil.CheckoutRoot(cwd, "the ledger's repository") + if err != nil { + return "", &exitError{Code: 2, Msg: "abcd source ledger: " + err.Error() + " (or name the ledger with --repo)"} + } + sha := gitutil.RootCommit(root) + if !gitutil.IsFullSHA(sha) { + return "", &exitError{Code: 2, Msg: "abcd source ledger: this checkout has no commit to name its ledger by; name it with --repo"} + } + return sha[:12], nil +} + +func newSourceLedgerCommand(asJSON *bool, corpusFlag *string) *cobra.Command { + var ( + repo, decision, claim, key, locator, influence string + usedIn []string + corrects, flip int + list bool + ) + cmd := &cobra.Command{ + Use: "ledger", + Short: "Append an influence record to this repository's ledger; --flip cites one; --list reads it", + Long: "Append one influence record — {ts, repo, decision_ref, claim, source_key, locator,\n" + + "influence, cited_publicly: false} — to this repository's ledger in the corpus, and\n" + + "commit it. The ledger is append-only: a correction is a new line (--corrects N).\n\n" + + "--flip N is the person's act of citing line N publicly. It checks the source first\n" + + "(adr-41 gate 1: the folder is public/ and permission_status is citable), refuses\n" + + "naming the failing gate, and on success appends a NEW line with cited_publicly true.\n" + + "An agent never runs it. --list prints the ledger, numbered.\n\n" + + "The repository is named by its root commit's first twelve hex digits unless --repo\n" + + "names it.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := sourceCorpusDir(*corpusFlag) + if err != nil { + return err + } + r, err := ledgerRepo(repo) + if err != nil { + return err + } + switch { + case list: + lines, err := source.List(dir, r) + if err != nil { + return sourceError("ledger", dir, err) + } + if lines == nil { + lines = []source.AppendResult{} + } + return render(cmd.OutOrStdout(), *asJSON, lines, func(w io.Writer) { + fmt.Fprintf(w, "abcd source ledger — %s, %d line%s\n", r, len(lines), pluralS(len(lines))) + for _, l := range lines { + mark := "" + switch { + case l.Record.Flips != 0: + mark = fmt.Sprintf(" [cites line %d]", l.Record.Flips) + case l.Record.Corrects != 0: + mark = fmt.Sprintf(" [corrects line %d]", l.Record.Corrects) + } + fmt.Fprintf(w, " %3d %s %s %s — %s%s\n", l.Line, termsafe.Sanitize(l.Record.SourceKey), l.Record.Influence, + termsafe.Sanitize(l.Record.DecisionRef), termsafe.Sanitize(l.Record.Claim), mark) + } + }) + case flip != 0: + res, err := source.Flip(dir, r, flip, time.Now()) + if err != nil { + return sourceError("ledger", dir, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "abcd source ledger — line %d cites line %d publicly (%s), committed in the corpus\n", res.Line, flip, res.Record.SourceKey) + }) + } + res, err := source.Append(source.AppendRequest{Corpus: dir, Repo: r, DecisionRef: decision, Claim: claim, + SourceKey: key, Locator: locator, Influence: influence, UsedIn: usedIn, Corrects: corrects, Now: time.Now()}) + if err != nil { + return sourceError("ledger", dir, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "abcd source ledger — line %d: %s → %s (%s), committed in the corpus\n", + res.Line, res.Record.SourceKey, termsafe.Sanitize(res.Record.DecisionRef), res.Record.Influence) + fmt.Fprintln(w, " cited_publicly: false — citing it is the person's call (abcd source ledger --flip)") + }) + }, + } + f := cmd.Flags() + f.StringVar(&repo, "repo", "", "the ledger's repository handle (default: this checkout's root commit, 12 hex digits)") + f.StringVar(&decision, "decision", "", "the decision influenced: a DECISIONS.md date, an ADR or intent id, or free text") + f.StringVar(&claim, "claim", "", "what was decided or claimed") + f.StringVar(&key, "source", "", "the source key") + f.StringVar(&locator, "locator", "", "where in the source (pp., §)") + f.StringVar(&influence, "influence", "", "the influence: "+enumHelp(source.Influences)) + f.StringArrayVar(&usedIn, "used-in", nil, "a repository-relative path the influence landed in (repeatable)") + f.IntVar(&corrects, "corrects", 0, "the line this record corrects") + f.IntVar(&flip, "flip", 0, "cite line N publicly (the person's act; checks the source's permission first)") + f.BoolVar(&list, "list", false, "print the ledger, numbered (read-only)") + cmd.MarkFlagsMutuallyExclusive("flip", "list") + return cmd +} + +func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Command { + var refresh bool + cmd := &cobra.Command{ + Use: "sync-banlist", + Short: "Project confidential titles and aliases into this repository's untracked private banlist", + Long: "Regenerate the corpus's block in this repository's untracked private banlist\n" + + "(.abcd/.work.local/private-names.txt, the banlist verb's private layer): every\n" + + "confidential source's title and aliases, and its authors under ban_authors, as\n" + + "whitespace-flexible, case-insensitive phrases. Lines outside the block survive.\n" + + "A corpus whose folders and entries disagree is refused and nothing is written.\n\n" + + "--refresh is the pre-commit guard's mode: with no corpus it says so on one line and\n" + + "exits 0.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := sourceCorpusDir(*corpusFlag) + if err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return err + } + root, err := gitutil.CheckoutRoot(cwd, "the private banlist") + if err != nil { + return &exitError{Code: 2, Msg: "abcd source sync-banlist: " + err.Error() + " (nothing written)"} + } + res, err := source.SyncBanlist(dir, root) + if refresh && errors.Is(err, source.ErrNoCorpus) { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: no sources corpus at %s — generated banlist block not refreshed (skipped)\n", + fsutil.RedactHome(dir)) + return nil + } + if err != nil { + return sourceError("sync-banlist", dir, err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + state := "unchanged" + switch { + case res.Block.Created: + state = "created the store" + case res.Block.Changed: + state = "rewrote the block" + } + fmt.Fprintf(w, "abcd source sync-banlist — %d confidential source%s, %d pattern%s in %s (%s)\n", + res.Sources, pluralS(res.Sources), res.Block.Entries, pluralS(res.Block.Entries), res.Block.Path, state) + }) + }, + } + cmd.Flags().BoolVar(&refresh, "refresh", false, "the guard's mode: an absent corpus is a one-line notice and exit 0") + return cmd +} + +func newSourceCiteCheckCommand(asJSON *bool, corpusFlag *string) *cobra.Command { + return &cobra.Command{ + Use: "cite-check <file|->", + Short: "Scan text for confidential sources; report offenders by key only (exit 1 on a hit)", + Long: "Scan a file, or stdin with -, for every confidential source's title, aliases and\n" + + "opted-in authors, through the private banlist's matcher — the engine the pre-commit\n" + + "guard runs. Offenders are reported by key, field, line and byte offset, never by the\n" + + "text matched, so the report is safe to relay. Exit 1 when anything is found.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir, err := sourceCorpusDir(*corpusFlag) + if err != nil { + return err + } + var text []byte + if args[0] == "-" { + text, err = io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxScanBytes+1)) + if err == nil && len(text) > maxScanBytes { + err = fmt.Errorf("stdin is over %d bytes", maxScanBytes) + } + } else { + text, err = fsutil.ReadGuarded(args[0], maxScanBytes) + } + if err != nil { + return &exitError{Code: 2, Msg: "abcd source cite-check: the text cannot be read (absent, oversize, or not a regular file)"} + } + rep, err := source.CiteCheck(dir, text) + if err != nil { + return sourceError("cite-check", dir, err) + } + if rerr := render(cmd.OutOrStdout(), *asJSON, rep, func(w io.Writer) { + if rep.Clean() { + fmt.Fprintf(w, "abcd source cite-check — clean against %d confidential source%s\n", rep.Sources, pluralS(rep.Sources)) + return + } + fmt.Fprintf(w, "abcd source cite-check — %d finding%s (the matched text is withheld)\n", len(rep.Findings), pluralS(len(rep.Findings))) + for _, f := range rep.Findings { + fmt.Fprintf(w, " %s %s line %d, byte %d\n", f.Source, f.Field, f.Line, f.Offset) + } + }); rerr != nil { + return rerr + } + if !rep.Clean() { + return &exitError{Code: 1} + } + return nil + }, + } +} + +// applySourceFlagErrors withholds the offending token from a flag-parse failure on +// the source verbs, as the banlist verbs do: an argument read as a flag may be a +// confidential title or alias, and cobra's own message quotes it. It runs after the +// tree-wide usage tagging, which would otherwise replace it. +func applySourceFlagErrors(root *cobra.Command) { + for _, cmd := range root.Commands() { + if cmd.Name() != "source" { + continue + } + cmd.SetFlagErrorFunc(sourceFlagError) + for _, sub := range cmd.Commands() { + sub.SetFlagErrorFunc(sourceFlagError) + } + } +} + +func sourceFlagError(cmd *cobra.Command, err error) error { + _ = err // deliberately discarded: it may quote a confidential string + return &exitError{Code: 2, Msg: cmd.CommandPath() + ": a flag or its value was not understood (the text is withheld — it may be " + + "a confidential name); see --help, and pass identifying strings with --meta"} +} diff --git a/internal/surface/cli/source_surface_test.go b/internal/surface/cli/source_surface_test.go new file mode 100644 index 000000000..eb7c4f78b --- /dev/null +++ b/internal/surface/cli/source_surface_test.go @@ -0,0 +1,180 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// sourceCheckout stands the process in a fresh repository under a temp HOME, so +// the default corpus (~/.abcd/sources) is a temp path and never the real one, and +// gives corpus commits a fixture identity. +func sourceCheckout(t *testing.T) (home, repo string) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("GIT_AUTHOR_NAME", "Alice Example") + t.Setenv("GIT_AUTHOR_EMAIL", "alice@example.com") + t.Setenv("GIT_COMMITTER_NAME", "Alice Example") + t.Setenv("GIT_COMMITTER_EMAIL", "alice@example.com") + repo = filepath.Join(home, "repo") + gitInitAt(t, repo) + writeRel(t, repo, ".gitignore", ".abcd/.work.local/\n") + gitCmd(t, repo, "add", "-A") + gitCommit(t, repo, "commit", "-q", "-m", "base") + t.Chdir(repo) + return home, repo +} + +func runSource(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errb bytes.Buffer + code = Run(append([]string{"source"}, args...), &out, &errb) + return code, out.String(), errb.String() +} + +// TestSourceNoCorpusSaysSoOnEveryVerb is AC6 at the front door: with no corpus, +// every verb says so on one line and exits with the distinct no-corpus code, and +// the guard's refresh mode says so and exits 0. Nothing creates the corpus. +func TestSourceNoCorpusSaysSoOnEveryVerb(t *testing.T) { + home, _ := sourceCheckout(t) + for _, args := range [][]string{ + {}, + {"ledger", "--list"}, + {"sync-banlist"}, + {"cite-check", "-"}, + {"declassify", "conf2026a"}, + {"add", "--key", "k2026x", "--title", "Some Title", "--public", "--url", "https://example.com/x"}, + } { + code, stdout, stderr := runSource(t, args...) + if code != 3 { + t.Errorf("%v: exit %d, want 3\n%s%s", args, code, stdout, stderr) + } + if !strings.Contains(stdout+stderr, "no sources corpus") { + t.Errorf("%v: does not say the corpus is absent\n%s%s", args, stdout, stderr) + } + noHomePath(t, home, stdout+stderr) + } + code, stdout, stderr := runSource(t, "sync-banlist", "--refresh") + if code != 0 || strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "no sources corpus") { + t.Fatalf("refresh with no corpus: exit %d\n%s%s", code, stdout, stderr) + } + if _, err := os.Stat(filepath.Join(home, ".abcd", "sources")); !os.IsNotExist(err) { + t.Fatal("a verb created the corpus") + } +} + +// TestSourceEndToEnd drives the whole personal cycle through the CLI: init, a +// confidential and a public add, a ledger line, a refused flip, the banlist sync, +// a cite-check that names the key only, declassification, and the flip it opens. +func TestSourceEndToEnd(t *testing.T) { + home, repo := sourceCheckout(t) + src := t.TempDir() + notes := filepath.Join(src, "notes.md") + if err := os.WriteFile(notes, []byte("the body\n"), 0o600); err != nil { + t.Fatal(err) + } + meta := filepath.Join(src, "meta.json") + if err := os.WriteFile(meta, []byte(`{"title":"Quiet Harbour Working Notes","aliases":["harbourwatch"]}`), 0o600); err != nil { + t.Fatal(err) + } + + if code, out, errs := runSource(t, "init"); code != 0 { + t.Fatalf("init: %d %s%s", code, out, errs) + } + code, out, errs := runSource(t, "add", notes, "--key", "conf2026a", "--meta", meta, "--confidential", "--json") + if code != 0 { + t.Fatalf("add confidential: %d %s%s", code, out, errs) + } + if strings.Contains(out+errs, "Harbour") { + t.Fatalf("add echoes the confidential title:\n%s%s", out, errs) + } + if code, out, errs := runSource(t, "add", "--key", "survey2026", "--title", "An Open Survey", "--public", "--url", "https://example.com/survey"); code != 0 { + t.Fatalf("add public: %d %s%s", code, out, errs) + } + if code, _, errs := runSource(t, "add", notes, "--key", "x2026", "--title", "No Class Given"); code != 2 || !strings.Contains(errs, "class") { + t.Fatalf("add with no class: %d %s", code, errs) + } + + code, out, errs = runSource(t, "ledger", "--decision", "adr-41", "--claim", "append-only", "--source", "conf2026a", "--influence", "supports", "--json") + if code != 0 { + t.Fatalf("ledger: %d %s%s", code, out, errs) + } + var line struct { + Line int `json:"line"` + Record struct { + Repo string `json:"repo"` + CitedPublicly bool `json:"cited_publicly"` + } `json:"record"` + } + if err := json.Unmarshal([]byte(out), &line); err != nil || line.Line != 1 || line.Record.CitedPublicly || len(line.Record.Repo) != 12 { + t.Fatalf("ledger output: %v %s", err, out) + } + if code, out, errs := runSource(t, "ledger", "--flip", "1"); code != 2 || !strings.Contains(out+errs, "gate 1") { + t.Fatalf("flip of an unpermitted line: %d %s%s", code, out, errs) + } + + if code, out, errs := runSource(t, "sync-banlist"); code != 0 || !strings.Contains(out, "1 confidential source") { + t.Fatalf("sync-banlist: %d %s%s", code, out, errs) + } + store, err := os.ReadFile(filepath.Join(repo, ".abcd", ".work.local", "private-names.txt")) + if err != nil || !strings.Contains(string(store), "sources/conf2026a/title") { + t.Fatalf("store: %v\n%s", err, store) + } + + doc := filepath.Join(src, "draft.md") + if err := os.WriteFile(doc, []byte("We follow the quiet harbour working notes here.\n"), 0o600); err != nil { + t.Fatal(err) + } + code, out, errs = runSource(t, "cite-check", doc) + if code != 1 || !strings.Contains(out, "conf2026a") || strings.Contains(strings.ToLower(out+errs), "harbour") { + t.Fatalf("cite-check: %d\n%s%s", code, out, errs) + } + var stdin bytes.Buffer + stdin.WriteString("an unrelated sentence\n") + if code, out, errs := runSourceStdin(t, &stdin, "cite-check", "-"); code != 0 || !strings.Contains(out, "clean") { + t.Fatalf("cite-check clean: %d %s%s", code, out, errs) + } + + if code, out, errs := runSource(t, "declassify", "conf2026a"); code != 0 { + t.Fatalf("declassify: %d %s%s", code, out, errs) + } + if code, out, errs := runSource(t, "sync-banlist", "--refresh"); code != 0 { + t.Fatalf("refresh: %d %s%s", code, out, errs) + } + store, _ = os.ReadFile(filepath.Join(repo, ".abcd", ".work.local", "private-names.txt")) + if strings.Contains(string(store), "sources/conf2026a") { + t.Fatalf("declassified key survived the refresh:\n%s", store) + } + if code, out, errs := runSource(t, "ledger", "--flip", "1"); code != 0 { + t.Fatalf("flip after declassification: %d %s%s", code, out, errs) + } + code, out, errs = runSource(t, "--json") + if code != 0 || !strings.Contains(out, `"present": true`) { + t.Fatalf("status: %d %s%s", code, out, errs) + } + noHomePath(t, home, out+errs) +} + +func runSourceStdin(t *testing.T, stdin *bytes.Buffer, args ...string) (int, string, string) { + t.Helper() + var out, errb bytes.Buffer + root := NewRootCommand() + root.SetArgs(append([]string{"source"}, args...)) + root.SetOut(&out) + root.SetErr(&errb) + root.SetIn(stdin) + err := root.Execute() + code := 0 + if err != nil { + code = 1 + if c, ok := err.(interface{ ExitCode() int }); ok { + code = c.ExitCode() + } + errb.WriteString(err.Error()) + } + return code, out.String(), errb.String() +} From 5bc0ebdc2b235a1221fc43934547906fe4b02bae Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:06:01 +0100 Subject: [PATCH 006/107] =?UTF-8?q?chore:=20close=20spc-31=20=E2=80=94=20i?= =?UTF-8?q?td-76=20ships=20the=20source=20provenance=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abcd spec close spc-31 --impact additive` moves the spec to closed/ and the intent from planned/ to shipped/; every link that named the planned path now names the shipped one. The spec's first validation — re-establishing this repository's own corpus through the verbs — is not an acceptance criterion and is left to the person whose corpus it is. Delivers: itd-76 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/13-consult.md | 2 +- .abcd/development/brief/04-surfaces/31-source.md | 2 +- .../development/decisions/adrs/0041-corpus-trust-boundary.md | 2 +- ...-shares-one-bibliography-without-sharing-anyone-s-corp.md | 2 +- ...r-is-reconstructed-from-the-provenance-ledger-claims-g.md | 2 +- .../intents/drafts/itd-77-relocatable-user-home.md | 2 +- .../{planned => shipped}/itd-76-source-provenance-ledger.md | 5 +++++ .../plans/2026-07-08-confidential-sources-scaffold.md | 2 +- .../notes/2026-07-08-confidential-sources-provenance-sota.md | 2 +- .../{open => closed}/spc-31-source-provenance-ledger.md | 0 10 files changed, 13 insertions(+), 8 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-76-source-provenance-ledger.md (98%) rename .abcd/development/specs/{open => closed}/spc-31-source-provenance-ledger.md (100%) diff --git a/.abcd/development/brief/04-surfaces/13-consult.md b/.abcd/development/brief/04-surfaces/13-consult.md index 8346ce2bd..302b38c3b 100644 --- a/.abcd/development/brief/04-surfaces/13-consult.md +++ b/.abcd/development/brief/04-surfaces/13-consult.md @@ -150,7 +150,7 @@ corpus and its ledger. - The trust boundary the hard rule restates: [adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md), brief invariant 9 ([`../02-constraints/03-invariants.md`](../02-constraints/03-invariants.md)) -- Consuming intent: [itd-76](../../intents/planned/itd-76-source-provenance-ledger.md) +- Consuming intent: [itd-76](../../intents/shipped/itd-76-source-provenance-ledger.md) <!-- surface-appendix:begin — generated from the command tree by `go generate ./internal/surface/cli`; never edit by hand --> diff --git a/.abcd/development/brief/04-surfaces/31-source.md b/.abcd/development/brief/04-surfaces/31-source.md index 5946ac946..ab5bb35fe 100644 --- a/.abcd/development/brief/04-surfaces/31-source.md +++ b/.abcd/development/brief/04-surfaces/31-source.md @@ -184,7 +184,7 @@ cannot perform. - The trust boundary: [adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md), brief invariant 9 ([`../02-constraints/03-invariants.md`](../02-constraints/03-invariants.md)) -- Consuming intent: [itd-76](../../intents/planned/itd-76-source-provenance-ledger.md) +- Consuming intent: [itd-76](../../intents/shipped/itd-76-source-provenance-ledger.md) <!-- surface-appendix:begin — generated from the command tree by `go generate ./internal/surface/cli`; never edit by hand --> diff --git a/.abcd/development/decisions/adrs/0041-corpus-trust-boundary.md b/.abcd/development/decisions/adrs/0041-corpus-trust-boundary.md index 33eab85a1..7c5dd5866 100644 --- a/.abcd/development/decisions/adrs/0041-corpus-trust-boundary.md +++ b/.abcd/development/decisions/adrs/0041-corpus-trust-boundary.md @@ -14,7 +14,7 @@ related_adrs: [adr-30] ## Context -The sources corpus ([itd-76](../../intents/planned/itd-76-source-provenance-ledger.md)) +The sources corpus ([itd-76](../../intents/shipped/itd-76-source-provenance-ledger.md)) lets an agent consult material the user is not free to name in public. Anything that makes consultation safe rests on two boundaries holding mechanically, regardless of which surface — personal verbs, team share/ingest diff --git a/.abcd/development/intents/drafts/itd-126-a-team-shares-one-bibliography-without-sharing-anyone-s-corp.md b/.abcd/development/intents/drafts/itd-126-a-team-shares-one-bibliography-without-sharing-anyone-s-corp.md index d4eccc085..1267c17c9 100644 --- a/.abcd/development/intents/drafts/itd-126-a-team-shares-one-bibliography-without-sharing-anyone-s-corp.md +++ b/.abcd/development/intents/drafts/itd-126-a-team-shares-one-bibliography-without-sharing-anyone-s-corp.md @@ -14,7 +14,7 @@ impact: additive ## Press Release -> **Citation data travels through the repo; corpora never do.** Alice works from a personal sources corpus ([itd-76](../planned/itd-76-source-provenance-ledger.md), which this intent `refines`); their teammate Bob has their own, or none. `abcd source share` writes a public source's *citation data* into the repo's committed CSL-JSON references store — `.abcd/development/research/references.csl.json`, the store the acknowledgements list already reads (maintainer ruling, 2026-08-16: one committed bibliography, not a second exchange file) — and refuses any `confidential: true` entry mechanically. `abcd source ingest` imports the repo's shared entries into the local corpus. Documents and influence ledgers never travel — only bibliography. +> **Citation data travels through the repo; corpora never do.** Alice works from a personal sources corpus ([itd-76](../shipped/itd-76-source-provenance-ledger.md), which this intent `refines`); their teammate Bob has their own, or none. `abcd source share` writes a public source's *citation data* into the repo's committed CSL-JSON references store — `.abcd/development/research/references.csl.json`, the store the acknowledgements list already reads (maintainer ruling, 2026-08-16: one committed bibliography, not a second exchange file) — and refuses any `confidential: true` entry mechanically. `abcd source ingest` imports the repo's shared entries into the local corpus. Documents and influence ledgers never travel — only bibliography. > > "I just ingest the repo's shared references," said Bob, "and every public source Alice worked from is in my own local store, ready to consult." diff --git a/.abcd/development/intents/drafts/itd-127-a-paper-is-reconstructed-from-the-provenance-ledger-claims-g.md b/.abcd/development/intents/drafts/itd-127-a-paper-is-reconstructed-from-the-provenance-ledger-claims-g.md index eedc4f646..bce6a32db 100644 --- a/.abcd/development/intents/drafts/itd-127-a-paper-is-reconstructed-from-the-provenance-ledger-claims-g.md +++ b/.abcd/development/intents/drafts/itd-127-a-paper-is-reconstructed-from-the-provenance-ledger-claims-g.md @@ -14,7 +14,7 @@ impact: additive ## Press Release -> **A paper whose claims trace to decisions, and whose decisions trace to sources, is reconstructable rather than rewritten from memory.** The provenance ledger ([itd-76](../planned/itd-76-source-provenance-ledger.md), which this intent `refines`) already records which source influenced which decision with what claim. Paper reconstruction walks that trail: claims grouped by decision, citations resolved from the bibliography, rendered to PDF and HTML from one markdown source. The public render is proven clean twice, independently: structurally — it renders from a *generated* bibliography that omits confidential entries, so an unpermitted key fails the build — and by a deterministic post-render check of the output's citations against both gates (`permission_status` on the source, `cited_publicly` on the ledger line). +> **A paper whose claims trace to decisions, and whose decisions trace to sources, is reconstructable rather than rewritten from memory.** The provenance ledger ([itd-76](../shipped/itd-76-source-provenance-ledger.md), which this intent `refines`) already records which source influenced which decision with what claim. Paper reconstruction walks that trail: claims grouped by decision, citations resolved from the bibliography, rendered to PDF and HTML from one markdown source. The public render is proven clean twice, independently: structurally — it renders from a *generated* bibliography that omits confidential entries, so an unpermitted key fails the build — and by a deterministic post-render check of the output's citations against both gates (`permission_status` on the source, `cited_publicly` on the ledger line). > > "When the paper behind a decision is finally published, I flip one flag and the citation appears in the next render," said Alice, "with the whole influence trail already written." diff --git a/.abcd/development/intents/drafts/itd-77-relocatable-user-home.md b/.abcd/development/intents/drafts/itd-77-relocatable-user-home.md index 856537b70..d0b0e6735 100644 --- a/.abcd/development/intents/drafts/itd-77-relocatable-user-home.md +++ b/.abcd/development/intents/drafts/itd-77-relocatable-user-home.md @@ -14,7 +14,7 @@ builds_on: [] ## Press Release -> **One user-level home for everything abcd keeps outside your repos — blessed by default, movable by choice.** abcd's repo-tree `.abcd/` tiers cover what belongs to a project. But some state belongs to the *person*: the confidential-sources corpus ([itd-76](../planned/itd-76-source-provenance-ledger.md)), and whatever user-scoped configuration follows it. That state lives in abcd's user-level home — `~/.abcd/` by default, the same dotfile convention as every tool a developer already trusts with home-directory state. The default is only a default: a guided, wizard-style flow moves the home wherever the user's filing system wants it — say, alongside their projects root — updates the recorded location, and verifies every consumer (guards, skills, verbs) resolves the new path before declaring the move done. The user tier is *additive*: repo `.abcd/` tiers are untouched, and a repo remains fully functional on machines where no user home exists at all. +> **One user-level home for everything abcd keeps outside your repos — blessed by default, movable by choice.** abcd's repo-tree `.abcd/` tiers cover what belongs to a project. But some state belongs to the *person*: the confidential-sources corpus ([itd-76](../shipped/itd-76-source-provenance-ledger.md)), and whatever user-scoped configuration follows it. That state lives in abcd's user-level home — `~/.abcd/` by default, the same dotfile convention as every tool a developer already trusts with home-directory state. The default is only a default: a guided, wizard-style flow moves the home wherever the user's filing system wants it — say, alongside their projects root — updates the recorded location, and verifies every consumer (guards, skills, verbs) resolves the new path before declaring the move done. The user tier is *additive*: repo `.abcd/` tiers are untouched, and a repo remains fully functional on machines where no user home exists at all. > > "My whole development world lives under one directory, and I wanted abcd's user state in there too, not scattered in my home directory," said Alice. "One command moved it, re-pointed everything that reads it, and proved the pre-commit guard still found my banlist before it called the move complete." diff --git a/.abcd/development/intents/planned/itd-76-source-provenance-ledger.md b/.abcd/development/intents/shipped/itd-76-source-provenance-ledger.md similarity index 98% rename from .abcd/development/intents/planned/itd-76-source-provenance-ledger.md rename to .abcd/development/intents/shipped/itd-76-source-provenance-ledger.md index 6c70bfe6a..adf81b228 100644 --- a/.abcd/development/intents/planned/itd-76-source-provenance-ledger.md +++ b/.abcd/development/intents/shipped/itd-76-source-provenance-ledger.md @@ -58,3 +58,8 @@ None stated. - Ledger ownership once work spans machines: **explicitly deferred** (maintainer ruling, 2026-08-16) — per-repo files in the user-level corpus serve one machine; revisit when a second machine actually exists. - The share/ingest questions that previously lived here (conflict shape between teammates, provenance marks on ingested entries) travel with [itd-126](../drafts/itd-126-a-team-shares-one-bibliography-without-sharing-anyone-s-corp.md). + +## Audit Notes + +<!-- abcd-review: OWED receipt=rcp-595934bbc552 --> +Fidelity review OWED (receipt rcp-595934bbc552). diff --git a/.abcd/development/plans/2026-07-08-confidential-sources-scaffold.md b/.abcd/development/plans/2026-07-08-confidential-sources-scaffold.md index 5e9b06b0b..8ab457175 100644 --- a/.abcd/development/plans/2026-07-08-confidential-sources-scaffold.md +++ b/.abcd/development/plans/2026-07-08-confidential-sources-scaffold.md @@ -1,6 +1,6 @@ # Plan: Confidential-sources corpus and provenance ledger (convention-first scaffold) -> Forward-looking record: [itd-76](../intents/planned/itd-76-source-provenance-ledger.md). +> Forward-looking record: [itd-76](../intents/shipped/itd-76-source-provenance-ledger.md). > Research grounding: [SOTA survey](../research/notes/2026-07-08-confidential-sources-provenance-sota.md). > This plan documents the convention-first scaffold that dogfoods the intent > before any Go code lands. diff --git a/.abcd/development/research/notes/2026-07-08-confidential-sources-provenance-sota.md b/.abcd/development/research/notes/2026-07-08-confidential-sources-provenance-sota.md index d90af37a3..ac8ea0a60 100644 --- a/.abcd/development/research/notes/2026-07-08-confidential-sources-provenance-sota.md +++ b/.abcd/development/research/notes/2026-07-08-confidential-sources-provenance-sota.md @@ -1,6 +1,6 @@ # SOTA survey: confidential source consultation, provenance ledgers, and paper reconstruction -Grounding for [itd-76](../../intents/planned/itd-76-source-provenance-ledger.md) +Grounding for [itd-76](../../intents/shipped/itd-76-source-provenance-ledger.md) and the [convention-first scaffold plan](../../plans/2026-07-08-confidential-sources-scaffold.md). Question: what is the best current (2024–2026) shape for a **local-only** system in which an agent may *consult* confidential documents, never *cites* diff --git a/.abcd/development/specs/open/spc-31-source-provenance-ledger.md b/.abcd/development/specs/closed/spc-31-source-provenance-ledger.md similarity index 100% rename from .abcd/development/specs/open/spc-31-source-provenance-ledger.md rename to .abcd/development/specs/closed/spc-31-source-provenance-ledger.md From 33805830fd8b371fc4523220b5a302d5db49d86b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:07:50 +0100 Subject: [PATCH 007/107] chore: capture the source-ledger review's seven findings The review of itd-76's lane (review-sources) returned FIX FIRST: the guard's sources refresh resolves an installed binary in both hook copies, discards the refresh's output, creates the private store, offers a legacy store no migration, and cite-check's offset reads as a column. The flip hazard registration is captured for the guard lane to take. Refs: iss-2609252007414882 Refs: iss-2609252007419997 Refs: iss-2609252007422630 Refs: iss-2609252007426016 Refs: iss-2609252007433563 Refs: iss-2609252007434356 Refs: iss-2609252007448074 Assisted-by: Claude:claude-opus-5-5 --- ...y-s-committed-pre-commit-guard-refreshes-the.md | 14 ++++++++++++++ ...ed-pre-commit-template-refreshes-the-sources.md | 14 ++++++++++++++ ...-guard-discards-the-sources-refresh-s-stdout.md | 14 ++++++++++++++ ...it-guard-s-sources-refresh-creates-abcd-work.md | 14 ++++++++++++++ ...yed-private-store-with-entries-is-refused-by.md | 14 ++++++++++++++ ...ite-check-reports-a-finding-as-line-l-byte-b.md | 14 ++++++++++++++ ...ledger-flip-is-the-person-s-act-under-adr-41.md | 14 ++++++++++++++ 7 files changed, 98 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md create mode 100644 .abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md create mode 100644 .abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md create mode 100644 .abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md create mode 100644 .abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md create mode 100644 .abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md create mode 100644 .abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md diff --git a/.abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md b/.abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md new file mode 100644 index 000000000..8fafad1a7 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007414882" +slug: "this-repository-s-committed-pre-commit-guard-refreshes-the" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".githooks/pre-commit" +--- + +This repository's committed pre-commit guard refreshes the sources corpus's generated banlist block with an INSTALLED abcd (the pinned PATH, then ~/.local/bin), which in this source checkout is the last release and stale by construction, against the dogfooding rule and against the commit-msg hook, which builds ./cmd/abcd from the checkout. The guard should build the checkout's own abcd the way commit-msg does, print one warning line and proceed when the build fails, and never resolve an installed binary. diff --git a/.abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md b/.abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md new file mode 100644 index 000000000..88658e65e --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007419997" +slug: "the-scaffolded-pre-commit-template-refreshes-the-sources" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/defaults/pre-commit" +--- + +The scaffolded pre-commit template refreshes the sources banlist block by default with a binary found on PATH or in ~/.local/bin, which answers by fiat the three questions iss-2609250834251447 holds as a ruling owed to the product thinker (how a scaffolded hook finds a binary, fail open or closed, default or opt-in). Until that ruling, the template should refresh only on repo-local opt-in, git config --local abcd.sourcesBinary naming an absolute path to a regular file, with no PATH search and never an environment variable, and otherwise print one line and proceed. diff --git a/.abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md b/.abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md new file mode 100644 index 000000000..37ad0ce27 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007422630" +slug: "the-pre-commit-guard-discards-the-sources-refresh-s-stdout" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".githooks/pre-commit" +--- + +The pre-commit guard discards the sources refresh's stdout and stderr (>/dev/null 2>&1), so an older abcd that has the source verb but projects fewer patterns rewrites the generated block weaker and the commit prints nothing about it: a silent downgrade. A binary with no source verb prints 'refresh failed' on every commit, noise that teaches the committer to ignore the one line that matters. The guard should print the binary's one-line count, and one clear remedy line when the binary lacks the verb. diff --git a/.abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md b/.abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md new file mode 100644 index 000000000..7b8c06bfb --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007426016" +slug: "the-pre-commit-guard-s-sources-refresh-creates-abcd-work" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/banlist/generated.go" +--- + +The pre-commit guard's sources refresh CREATES .abcd/.work.local/private-names.txt in whatever repository the commit runs in when a corpus has confidential entries and no private store exists, writing every confidential title, alias and opted-in author as plaintext patterns into a directory that may be cloud-synced or bind-mounted. The refresh should update an existing keyed store only; creating one stays the by-hand abcd source sync-banlist. diff --git a/.abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md b/.abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md new file mode 100644 index 000000000..17a4853b8 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007433563" +slug: "a-legacy-unkeyed-private-store-with-entries-is-refused-by" +severity: "minor" +category: "ux" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/banlist/generated.go" +--- + +A legacy (unkeyed) private store with entries is refused by abcd source sync-banlist with no migration path: the refusal asks the user to hand-key every line, and the pre-commit guard repeats a warning about it on every commit, for ever. The verb should offer a migration that keeps every line matching what it matched, and the guard should name it once, not warn on every commit. diff --git a/.abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md b/.abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md new file mode 100644 index 000000000..003b424b9 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007434356" +slug: "abcd-source-cite-check-reports-a-finding-as-line-l-byte-b" +severity: "minor" +category: "documentation" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/source/guard.go" +--- + +abcd source cite-check reports a finding as 'line L, byte B' where B is grep -b's FILE offset of the matched span, which also includes the leading boundary byte, so a reader takes it for a column in line L. The offset must be documented and rendered as what it is, consistently in the type, the help and the surface pages. diff --git a/.abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md b/.abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md new file mode 100644 index 000000000..d30648b0f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252007448074-abcd-source-ledger-flip-is-the-person-s-act-under-adr-41.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252007448074" +slug: "abcd-source-ledger-flip-is-the-person-s-act-under-adr-41" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/source/ledger.go" +--- + +abcd source ledger --flip is the person's act under adr-41 gate 2 (a line is flipped to public citation only by the human), but the shell guard's default hazard registry does not name it, so an agent can run it unprompted; ledger.go refuses a confidential or non-citable source mechanically, so the exposure is misattributed provenance, not leakage. Register the command in the guard's default registry once the guard lane's registry edit has landed. From 3255cd704b7d8ed529a042d1f8b12e39312776a4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:11:37 +0100 Subject: [PATCH 008/107] fix(source): the guard's refresh never creates the private store `abcd source sync-banlist --refresh` created .abcd/.work.local/private-names.txt in whatever repository the commit ran in whenever the corpus had something to ban, writing every confidential title, alias and opted-in author into a local tier that may be cloud-synced or bind-mounted. The refresh now updates a store that already exists and declares the keyed format, and nothing else: an absent store is one line on stderr and exit 0, with no tier, lock or file created, and an unkeyed store is refused rather than declared. Creating the store stays the by-hand sync. banlist.RefreshGeneratedBlock is the core half; the guard's own check before it runs the binary lands with the hook changes. Refs: iss-2609252007426016 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-source.md | 8 ++- commands/source.md | 8 +-- docs/reference/cli/commands.md | 5 +- internal/core/banlist/generated.go | 37 ++++++++++++-- internal/core/banlist/generated_test.go | 40 +++++++++++++++ internal/core/source/guard.go | 17 ++++++- internal/core/source/source_test.go | 12 ++--- internal/surface/cli/source.go | 13 +++-- internal/surface/cli/source_surface_test.go | 50 +++++++++++++++++++ 9 files changed, 168 insertions(+), 22 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-source.md b/.abcd/development/brief/04-surfaces/31-source.md index ab5bb35fe..691af4740 100644 --- a/.abcd/development/brief/04-surfaces/31-source.md +++ b/.abcd/development/brief/04-surfaces/31-source.md @@ -109,7 +109,11 @@ The banlist sync projects every confidential source into a fenced block of this repository's untracked private banlist, `.abcd/.work.local/private-names.txt` (the private layer of [`/abcd:banlist`](20-banlist.md)): the title and each alias always, the authors only where the entry sets `ban_authors`. Lines outside the -fence, hand-written or verb-added, survive every sync. The committed pre-commit +fence, hand-written or verb-added, survive every sync. The by-hand sync creates +the store when there is something to ban; the refresh mode updates a store that +already exists and declares the keyed format, and never creates one, because a +store created by a commit's side effect would write every confidential title into +a repository's local tier without the person asking. The committed pre-commit guard, the copy this repository runs and the one `abcd ahoy` scaffolds alike, runs the sync in its refresh mode before it reads the store, so the block is never more than one commit stale, and then refuses a staged commit carrying a @@ -137,7 +141,7 @@ With no corpus at the configured location every verb but the creating one says so on one line and exits 3, a code distinct from a refusal, so a script can tell "nothing to check against" from "checked and clean". The guard says so on one line and lets the commit proceed; the sync's refresh mode, which the guard runs, does -the same and exits 0. A corpus the guard cannot refresh (no binary found, or a +the same and exits 0, as it does for a repository with no private store. A corpus the guard cannot refresh (no binary found, or a refresh that fails) is named on the commit, and the store is checked as it stands. diff --git a/commands/source.md b/commands/source.md index 6e33cca0d..a30135f34 100644 --- a/commands/source.md +++ b/commands/source.md @@ -81,9 +81,11 @@ checkout's root commit unless `--repo` names it. Projects every confidential source's title and aliases (authors only under `ban_authors`) into this repository's untracked private banlist, as a fenced block -it owns. The committed pre-commit guard runs `sync-banlist --refresh` on every -commit, so running it by hand matters after an add or a declassification, before -the next commit. A refusal naming keys means folders and entries disagree: repair +it owns. Run by hand, it creates the store when there is something to ban. The +committed pre-commit guard runs `sync-banlist --refresh` on every commit, which +updates a store that already exists and never creates one, so the first sync in a +repository is always the user's own, and running it by hand also matters after an +add or a declassification, before the next commit. A refusal naming keys means folders and entries disagree: repair those entries (the block already written keeps banning meanwhile). ## Scan before sharing diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index be7accbab..eae26d507 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1667,13 +1667,14 @@ confidential source's title and aliases, and its authors under ban_authors, as whitespace-flexible, case-insensitive phrases. Lines outside the block survive. A corpus whose folders and entries disagree is refused and nothing is written. ---refresh is the pre-commit guard's mode: with no corpus it says so on one line and +--refresh is the pre-commit guard's mode: it updates a private store that already +exists and never creates one. With no corpus, or no store, it says so on one line and exits 0. **Flags:** ``` - --refresh the guard's mode: an absent corpus is a one-line notice and exit 0 + --refresh the guard's mode: update an existing store only; an absent corpus or store is a one-line notice and exit 0 ``` ### `abcd spec` diff --git a/internal/core/banlist/generated.go b/internal/core/banlist/generated.go index 36fec566f..fbf74e648 100644 --- a/internal/core/banlist/generated.go +++ b/internal/core/banlist/generated.go @@ -262,6 +262,25 @@ func ScanText(patterns []KeyedPattern, text []byte) ([]Hit, error) { // block empties the block and keeps its fence. A second sync of the same entries // writes nothing (Changed is false). func SyncGeneratedBlock(repoRoot, owner string, entries []KeyedPattern) (GeneratedResult, error) { + return syncGeneratedBlock(repoRoot, owner, entries, true) +} + +// RefreshGeneratedBlock is SyncGeneratedBlock for a caller that must never opt a +// machine in: the pre-commit guard's refresh. It updates a store that already +// exists AND declares the keyed format, and nothing else. An absent store is +// ErrNoStore, with no tier, lock or file created, because creating the store writes +// every projected string into the repository's local tier, which is an act the +// person takes by hand (SyncGeneratedBlock), not a side effect of a commit. A store +// that does not declare the keyed format is ErrLegacyStore even when it holds no +// entries: the by-hand sync would declare the format there, and a refresh changes +// the block, never the store's format. +func RefreshGeneratedBlock(repoRoot, owner string, entries []KeyedPattern) (GeneratedResult, error) { + return syncGeneratedBlock(repoRoot, owner, entries, false) +} + +// syncGeneratedBlock is both: mayCreate says whether an absent store may be created +// and an entryless legacy one declared. +func syncGeneratedBlock(repoRoot, owner string, entries []KeyedPattern, mayCreate bool) (GeneratedResult, error) { res := GeneratedResult{Path: PrivateRelPath, Owner: owner} if !validKey(owner) || strings.Contains(owner, "/") { return res, fmt.Errorf("%w: block owner %q", ErrInvalidKey, owner) @@ -287,9 +306,16 @@ func SyncGeneratedBlock(repoRoot, owner string, entries []KeyedPattern) (Generat } res.Entries = len(entries) - // Nothing to project and no store: nothing to do, and no tier to create. - if _, err := readPrivate(repoRoot); errors.Is(err, ErrNoStore) && len(entries) == 0 { - return res, nil + // Nothing to project and no store: nothing to do, and no tier to create. A + // refresh never creates one, so an absent store is its refusal, raised before + // the lock below creates the tier. + if _, err := readPrivate(repoRoot); errors.Is(err, ErrNoStore) { + if !mayCreate { + return res, err + } + if len(entries) == 0 { + return res, nil + } } if err := requireIgnoredStore(repoRoot); err != nil { return res, err @@ -300,6 +326,9 @@ func SyncGeneratedBlock(repoRoot, owner string, entries []KeyedPattern) (Generat switch { case err == nil: case errors.Is(err, ErrNoStore): + if !mayCreate { + return err + } if len(entries) == 0 { return nil } @@ -312,7 +341,7 @@ func SyncGeneratedBlock(repoRoot, owner string, entries []KeyedPattern) (Generat if perr != nil { return perr } - if !keyed && len(parsed) > 0 { + if !keyed && (len(parsed) > 0 || !mayCreate) { return legacyStoreRefusal("sync a generated block into") } body := string(data) diff --git a/internal/core/banlist/generated_test.go b/internal/core/banlist/generated_test.go index 703c85e90..4f3d43629 100644 --- a/internal/core/banlist/generated_test.go +++ b/internal/core/banlist/generated_test.go @@ -258,3 +258,43 @@ func TestSyncGeneratedBlockRefusals(t *testing.T) { }) } } + +// TestRefreshGeneratedBlockUpdatesAnExistingKeyedStoreOnly is the guard's mode +// (iss-2609252007426016): an absent store stays absent — no tier, no lock file — and +// a store that does not declare the keyed format is refused, even one with no +// entries, which the by-hand sync would declare. A keyed store is refreshed exactly +// as the by-hand sync refreshes it. +func TestRefreshGeneratedBlockUpdatesAnExistingKeyedStoreOnly(t *testing.T) { + needGrep(t) + p, _ := PhrasePattern("quiet harbour") + in := []KeyedPattern{{Key: "sources/a/title", Pattern: p}} + + root := t.TempDir() + if _, err := RefreshGeneratedBlock(root, "sources", in); !errors.Is(err, ErrNoStore) { + t.Fatalf("refresh of an absent store: %v, want ErrNoStore", err) + } + if _, err := os.Stat(filepath.Join(root, ".abcd")); !os.IsNotExist(err) { + t.Fatalf("a refresh of an absent store created the tier (%v)", err) + } + + for _, legacy := range []string{"# only a comment\n", "somepattern\n"} { + root := t.TempDir() + writePrivate(t, root, legacy) + if _, err := RefreshGeneratedBlock(root, "sources", in); !errors.Is(err, ErrLegacyStore) { + t.Fatalf("refresh of legacy store %q: %v, want ErrLegacyStore", legacy, err) + } + if readStore(t, root) != legacy { + t.Fatal("a refused refresh wrote the store") + } + } + + root = t.TempDir() + writePrivate(t, root, privateFormatDecl+"\nhand-key widgetworks\n") + res, err := RefreshGeneratedBlock(root, "sources", in) + if err != nil || res.Created || !res.Changed || res.Entries != 1 { + t.Fatalf("refresh of a keyed store: %+v %v", res, err) + } + if body := readStore(t, root); !strings.Contains(body, "sources/a/title ") || !strings.Contains(body, "hand-key widgetworks") { + t.Fatalf("store after refresh:\n%s", body) + } +} diff --git a/internal/core/source/guard.go b/internal/core/source/guard.go index 0b3662455..5a905e2bb 100644 --- a/internal/core/source/guard.go +++ b/internal/core/source/guard.go @@ -48,12 +48,21 @@ type SyncResult struct { Block banlist.GeneratedResult `json:"block"` } +// SyncOptions says how a banlist sync may treat the private store. +type SyncOptions struct { + // Refresh is the pre-commit guard's mode: update a private store that already + // exists and declares the keyed format, and never create one + // (banlist.RefreshGeneratedBlock). Without it the sync is the person's by-hand + // act, which creates the store when there is something to ban. + Refresh bool +} + // SyncBanlist projects the corpus's confidential entries into repoRoot's untracked // private banlist (the itd-74 private layer), as the generated block the corpus // owns. Hand-written entries outside the block survive; a declassified source's // strings leave it on the next sync. A corpus whose classes disagree is refused // before anything is written, so the block already there keeps banning. -func SyncBanlist(corpus, repoRoot string) (SyncResult, error) { +func SyncBanlist(corpus, repoRoot string, opts SyncOptions) (SyncResult, error) { c, err := Load(corpus) if err != nil { return SyncResult{}, err @@ -65,7 +74,11 @@ func SyncBanlist(corpus, repoRoot string) (SyncResult, error) { if err != nil { return SyncResult{}, err } - block, err := banlist.SyncGeneratedBlock(repoRoot, BlockOwner, pats) + sync := banlist.SyncGeneratedBlock + if opts.Refresh { + sync = banlist.RefreshGeneratedBlock + } + block, err := sync(repoRoot, BlockOwner, pats) if err != nil { return SyncResult{}, err } diff --git a/internal/core/source/source_test.go b/internal/core/source/source_test.go index bb1bdf3b6..521c3ada6 100644 --- a/internal/core/source/source_test.go +++ b/internal/core/source/source_test.go @@ -376,7 +376,7 @@ func TestSyncBanlistFeedsTheGuard(t *testing.T) { addPublic(t, corpus) repo := guardRepo(t) - res, err := SyncBanlist(corpus, repo) + res, err := SyncBanlist(corpus, repo, SyncOptions{}) if err != nil { t.Fatal(err) } @@ -454,7 +454,7 @@ func TestDeclassifyDropsTheBanAndOpensTheFlip(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := SyncBanlist(corpus, repo); err != nil { + if _, err := SyncBanlist(corpus, repo, SyncOptions{}); err != nil { t.Fatal(err) } store := filepath.Join(repo, ".abcd", ".work.local", "private-names.txt") @@ -471,7 +471,7 @@ func TestDeclassifyDropsTheBanAndOpensTheFlip(t *testing.T) { if st := git(t, corpus, "status", "--porcelain"); st != "" { t.Fatalf("declassification not committed:\n%s", st) } - if _, err := SyncBanlist(corpus, repo); err != nil { + if _, err := SyncBanlist(corpus, repo, SyncOptions{}); err != nil { t.Fatal(err) } if b, _ := os.ReadFile(store); strings.Contains(string(b), "sources/conf2026a") { @@ -489,7 +489,7 @@ func TestAMismatchedClassRefusesTheSync(t *testing.T) { corpus := newCorpus(t) addConfidential(t, corpus, false) repo := guardRepo(t) - if _, err := SyncBanlist(corpus, repo); err != nil { + if _, err := SyncBanlist(corpus, repo, SyncOptions{}); err != nil { t.Fatal(err) } store := filepath.Join(repo, ".abcd", ".work.local", "private-names.txt") @@ -499,7 +499,7 @@ func TestAMismatchedClassRefusesTheSync(t *testing.T) { } git(t, corpus, "mv", "confidential/conf2026a", "public/conf2026a") - _, err := SyncBanlist(corpus, repo) + _, err := SyncBanlist(corpus, repo, SyncOptions{}) if !errors.Is(err, ErrClassMismatch) || !strings.Contains(err.Error(), "conf2026a") { t.Fatalf("sync over a mismatch: %v", err) } @@ -529,7 +529,7 @@ func TestNoCorpusIsNamedByEveryStep(t *testing.T) { return err }, "flip": func() error { _, err := Flip(dir, "r", 1, fixedNow); return err }, - "sync": func() error { _, err := SyncBanlist(dir, repo); return err }, + "sync": func() error { _, err := SyncBanlist(dir, repo, SyncOptions{}); return err }, "cite-check": func() error { _, err := CiteCheck(dir, []byte("x")); return err }, "declassify": func() error { _, err := Declassify(dir, "k", ""); return err }, "list ledger": func() error { _, err := List(dir, "r"); return err }, diff --git a/internal/surface/cli/source.go b/internal/surface/cli/source.go index 1c5ae6fe5..c84358067 100644 --- a/internal/surface/cli/source.go +++ b/internal/surface/cli/source.go @@ -23,6 +23,7 @@ import ( "strings" "time" + "github.com/intentdriven/abcd/internal/core/banlist" "github.com/intentdriven/abcd/internal/core/source" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -434,7 +435,8 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman "confidential source's title and aliases, and its authors under ban_authors, as\n" + "whitespace-flexible, case-insensitive phrases. Lines outside the block survive.\n" + "A corpus whose folders and entries disagree is refused and nothing is written.\n\n" + - "--refresh is the pre-commit guard's mode: with no corpus it says so on one line and\n" + + "--refresh is the pre-commit guard's mode: it updates a private store that already\n" + + "exists and never creates one. With no corpus, or no store, it says so on one line and\n" + "exits 0.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -450,12 +452,17 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman if err != nil { return &exitError{Code: 2, Msg: "abcd source sync-banlist: " + err.Error() + " (nothing written)"} } - res, err := source.SyncBanlist(dir, root) + res, err := source.SyncBanlist(dir, root, source.SyncOptions{Refresh: refresh}) if refresh && errors.Is(err, source.ErrNoCorpus) { fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: no sources corpus at %s — generated banlist block not refreshed (skipped)\n", fsutil.RedactHome(dir)) return nil } + if refresh && errors.Is(err, banlist.ErrNoStore) { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: no private store at %s — the refresh never creates one; run `abcd source sync-banlist` to opt this repository in (skipped)\n", + banlist.PrivateRelPath) + return nil + } if err != nil { return sourceError("sync-banlist", dir, err) } @@ -472,7 +479,7 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman }) }, } - cmd.Flags().BoolVar(&refresh, "refresh", false, "the guard's mode: an absent corpus is a one-line notice and exit 0") + cmd.Flags().BoolVar(&refresh, "refresh", false, "the guard's mode: update an existing store only; an absent corpus or store is a one-line notice and exit 0") return cmd } diff --git a/internal/surface/cli/source_surface_test.go b/internal/surface/cli/source_surface_test.go index eb7c4f78b..d7e69bb6d 100644 --- a/internal/surface/cli/source_surface_test.go +++ b/internal/surface/cli/source_surface_test.go @@ -178,3 +178,53 @@ func runSourceStdin(t *testing.T, stdin *bytes.Buffer, args ...string) (int, str } return code, out.String(), errb.String() } + +// confidentialCorpus makes the default corpus under the temp HOME and adds one +// confidential source, so a sync has something to project. +func confidentialCorpus(t *testing.T) { + t.Helper() + src := t.TempDir() + notes := filepath.Join(src, "notes.md") + if err := os.WriteFile(notes, []byte("the body\n"), 0o600); err != nil { + t.Fatal(err) + } + meta := filepath.Join(src, "meta.json") + if err := os.WriteFile(meta, []byte(`{"title":"Quiet Harbour Working Notes","aliases":["harbourwatch"]}`), 0o600); err != nil { + t.Fatal(err) + } + if code, out, errs := runSource(t, "init"); code != 0 { + t.Fatalf("init: %d %s%s", code, out, errs) + } + if code, out, errs := runSource(t, "add", notes, "--key", "conf2026a", "--meta", meta, "--confidential"); code != 0 { + t.Fatalf("add confidential: %d %s%s", code, out, errs) + } +} + +// TestSourceRefreshNeverCreatesTheStore: the guard's refresh updates a private store +// that already exists and never creates one (iss-2609252007426016). Creating the +// store writes every confidential title into the repository's local tier, which is +// the by-hand sync's act, not a side effect of a commit. The refresh says so on one +// line, exits 0, and leaves no tier behind. +func TestSourceRefreshNeverCreatesTheStore(t *testing.T) { + home, repo := sourceCheckout(t) + confidentialCorpus(t) + code, stdout, stderr := runSource(t, "sync-banlist", "--refresh") + if code != 0 { + t.Fatalf("refresh with no store: exit %d\n%s%s", code, stdout, stderr) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd", ".work.local")); !os.IsNotExist(err) { + t.Fatalf("the refresh created the local tier or the store (%v)", err) + } + if strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "abcd source sync-banlist") { + t.Fatalf("the refresh does not say, on one line, that it created nothing\n%s%s", stdout, stderr) + } + noHomePath(t, home, stdout+stderr) + + // The by-hand sync is what creates it, and a refresh then updates it. + if code, out, errs := runSource(t, "sync-banlist"); code != 0 { + t.Fatalf("by-hand sync: %d %s%s", code, out, errs) + } + if code, out, errs := runSource(t, "sync-banlist", "--refresh"); code != 0 || !strings.Contains(out, "1 confidential source") { + t.Fatalf("refresh of an existing store: %d %s%s", code, out, errs) + } +} From 495273a44274c86705db1ce0795abe5c81c0814c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:18:47 +0100 Subject: [PATCH 009/107] feat(banlist): migrate a legacy private store, and name it in every refusal A legacy private store with entries was refused by `add`, `remove` and `abcd source sync-banlist` with no way forward but keying every line by hand, and the guard's sources refresh repeated that on every commit. `abcd banlist migrate` is the migration: the format declaration becomes line 1 and each whole-line pattern keeps its exact bytes under the key the guard already prints for it, entry-<its line>, so the store matches what it matched and a refusal names the same key before and after (the guard is driven over both to prove it). Comments and blank lines stay; each composed line is proved to parse back before anything is written; a keyed store is left alone. Every legacy refusal names the command. The sources refresh answers a legacy store with one line naming it and exit 0, so the guard can relay it without calling it a failure; the guard's print-once half lands with the hook changes. The migration lives on `banlist`, not as a `source sync-banlist` flag: the same refusal reaches a user of `banlist add` who has no corpus, and a command that needs one would be the wrong remedy there. Refs: iss-2609252007433563 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/20-banlist.md | 18 ++- .abcd/development/release/surface.json | 5 + commands/banlist.md | 25 ++++- docs/reference/cli/commands.md | 16 ++- internal/core/banlist/migrate.go | 93 +++++++++++++++ internal/core/banlist/migrate_test.go | 106 ++++++++++++++++++ internal/core/banlist/private.go | 14 ++- internal/surface/cli/banlist.go | 38 ++++++- internal/surface/cli/banlist_surface_test.go | 38 +++++++ internal/surface/cli/source.go | 8 +- internal/surface/cli/source_surface_test.go | 22 ++++ 11 files changed, 367 insertions(+), 16 deletions(-) create mode 100644 internal/core/banlist/migrate.go create mode 100644 internal/core/banlist/migrate_test.go diff --git a/.abcd/development/brief/04-surfaces/20-banlist.md b/.abcd/development/brief/04-surfaces/20-banlist.md index b2789bfa1..ad51a8a34 100644 --- a/.abcd/development/brief/04-surfaces/20-banlist.md +++ b/.abcd/development/brief/04-surfaces/20-banlist.md @@ -26,6 +26,7 @@ explicitly. |---|---|---| | `add` | — | shipped | | `list` | — | shipped | +| `migrate` | — | shipped | | `remove` | — | shipped | An add or a remove each names its layer, private or public, and neither @@ -98,6 +99,15 @@ pattern to the remainder after its first field. An add and a remove refuse a non-empty legacy store for the same reason: writing a keyed line into it would change what every *other* line means. +The migration is its own visible act, and every refusal names it. A migrate +puts the declaration on line 1 and keys each whole-line pattern, byte for byte, +under the synthetic key the guard already prints for it, `entry-<its line>`, so +the store matches exactly what it matched and a refusal names the same key +before and after. Comments and blank lines stay where they were; each composed +line is proved to parse back to its key and pattern before anything is written; +a keyed store is left alone, and no pattern is printed. It takes no layer, since +only the private layer has a legacy form. + The store has a second writer, and the format declaration is what lets the two share it. The sources corpus derives patterns from its confidential entries and maintains them inside a fenced generated block in the same file, refusing a legacy @@ -298,7 +308,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd banlist` -Sub-verbs: `abcd banlist add`, `abcd banlist list`, `abcd banlist remove`. +Sub-verbs: `abcd banlist add`, `abcd banlist list`, `abcd banlist migrate`, `abcd banlist remove`. Flags: none. @@ -322,6 +332,12 @@ Sub-verbs: none. | `--private` | bool | | `--public` | bool | +### `abcd banlist migrate` + +Sub-verbs: none. + +Flags: none. + ### `abcd banlist remove` Sub-verbs: none. diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index cf9933162..b1802f244 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -214,6 +214,11 @@ } ] }, + { + "path": "abcd banlist migrate", + "hidden": false, + "flags": [] + }, { "path": "abcd banlist remove", "hidden": false, diff --git a/commands/banlist.md b/commands/banlist.md index 186fe8485..24e956122 100644 --- a/commands/banlist.md +++ b/commands/banlist.md @@ -1,7 +1,7 @@ --- name: banlist -description: Maintain the two banned-names layers — the committed CI-enforced public list and the gitignored per-machine private list — by invoking the abcd binary. Bare invocation is a read-only render; add/remove act on one named layer. -argument-hint: "[list --private|--public] | add --private|--public <key> <pattern> [--severity blocker|warn] [--successor <text>] | remove --private|--public <key>" +description: Maintain the two banned-names layers — the committed CI-enforced public list and the gitignored per-machine private list — by invoking the abcd binary. Bare invocation is a read-only render; add/remove act on one named layer; migrate keys a legacy private store. +argument-hint: "[list --private|--public] | add --private|--public <key> <pattern> [--severity blocker|warn] [--successor <text>] | remove --private|--public <key> | migrate" --- # `/abcd:banlist` — banned names, two layers @@ -47,8 +47,8 @@ commit until they are fixed. `inert_lines` are lines it accepts but reads differ (a Perl-style escape, an inline flag group): the guard refuses nothing, and those names are unguarded while the store looks healthy. Report each by line number only. `keyed` reports the store's format; when it is false and there are entries, say that -the store is in the legacy whole-line format and that `add`/`remove` refuse until its -first line declares the keyed format. +the store is in the legacy whole-line format and that `add`/`remove` refuse until it +is migrated (below). `list` is the same render with an optional scope: @@ -87,8 +87,7 @@ path is not gitignored the add is refused: the whole layer rests on that file be untracked, so add the tier line the message names and re-run. If the store predates the keyed format — no `# abcd-banlist: keyed` first line, and at least one entry — `add` and `remove` refuse, because a keyed line written into it would change what -every other line means; the message names the one line the user adds by hand, after -which each existing whole-line pattern needs a key. +every other line means; the message names the migration below. A public add takes `--severity` (`blocker`, the default, or `warn`) and `--successor` (the replacement the finding cites; default "a generic term"), and @@ -109,6 +108,20 @@ A public removal is refused for a hand-curated entry (an id outside `names/`): those are edited in the config by a human, in a reviewable commit. An unknown key is refused rather than treated as a no-op. +## Migrate a legacy private store + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" banlist migrate --json +``` + +Converts a legacy private store to the keyed format in place: the declaration +becomes line 1 and every whole-line pattern keeps its exact bytes under the key the +guard already names it by, `entry-<its line>`, so nothing it matches changes. +Comments and blank lines survive. `migrated` is false when the store was already +keyed, and nothing is written then. Report the entry count; the binary prints no +pattern, and neither should you. `abcd source sync-banlist` and the pre-commit +guard's refresh of the sources block both name this command for a legacy store. + **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install provisions the binary into the plugin root, so this is the rung that fires for a plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index eae26d507..7fe69da19 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -140,6 +140,19 @@ Render the banlist layers; private entries render by key only --public the committed, CI-enforced layer (.abcd/docs-lint.json) ``` +#### `abcd banlist migrate` + +Key a legacy private store in place (every line keeps matching what it matched) + +**Usage:** `abcd banlist migrate` + +Convert a legacy private store (.abcd/.work.local/private-names.txt with no +'# abcd-banlist: keyed' first line, every line a whole-line pattern) to the keyed +format: the declaration becomes line 1, and each pattern keeps its exact bytes under +the key the guard already names it by, entry-<its line>. Comments and blank lines +survive. add, remove and `abcd source sync-banlist` refuse a legacy store with entries +until it is migrated. A keyed store is left alone. No pattern is printed. + #### `abcd banlist remove` Remove one banned-name entry from the named layer @@ -1668,7 +1681,8 @@ whitespace-flexible, case-insensitive phrases. Lines outside the block survive. A corpus whose folders and entries disagree is refused and nothing is written. --refresh is the pre-commit guard's mode: it updates a private store that already -exists and never creates one. With no corpus, or no store, it says so on one line and +exists and declares the keyed format, and never creates one. With no corpus, no store +or a legacy store (migrate it with `abcd banlist migrate`) it says so on one line and exits 0. **Flags:** diff --git a/internal/core/banlist/migrate.go b/internal/core/banlist/migrate.go new file mode 100644 index 000000000..73e6f02a1 --- /dev/null +++ b/internal/core/banlist/migrate.go @@ -0,0 +1,93 @@ +package banlist + +import ( + "fmt" + "strconv" + "strings" +) + +// MigrateResult is the outcome of a legacy-store migration. +type MigrateResult struct { + // Path is the private store, repo-relative. + Path string `json:"path"` + // Migrated reports that the store was legacy and is now keyed; a store that + // already declared the keyed format is left byte-for-byte alone (false). + Migrated bool `json:"migrated"` + // Entries counts the entries the store holds after the call. + Entries int `json:"entries"` +} + +// MigratePrivate converts a legacy private store — no format declaration, every +// entry a whole-line pattern — to the keyed format in place, so the verbs that +// refuse a legacy store (add, remove, a generated-block sync) can act on it. +// +// Nothing a line matches changes. Each entry keeps its exact pattern bytes under +// the key the guard already prints for it, entry-<its line in the legacy file>, so +// a refusal names the same key before and after. The declaration becomes line 1; +// every comment and blank line survives in place, and only a leading byte-order +// mark and the trailing blanks of an entry line (which neither reader ever +// matched) are dropped. Each composed line is proved to parse back to exactly its +// key and pattern before anything is written, and a line that would not is refused +// by number. A keyed store is left alone; an absent one is ErrNoStore. The store's +// contract holds as for AddPrivate: it must be gitignored, and the write is +// contained, atomic, 0600 and under the store's lock. No pattern is ever quoted. +func MigratePrivate(repoRoot string) (MigrateResult, error) { + res := MigrateResult{Path: PrivateRelPath} + data, err := readPrivate(repoRoot) + if err != nil { + return res, err + } + entries, keyed, err := parse(data) + if err != nil { + return res, err + } + if keyed { + res.Entries = countParsed(entries) + return res, nil + } + if err := requireIgnoredStore(repoRoot); err != nil { + return res, err + } + err = withPrivateLock(repoRoot, func() error { + data, err := readPrivate(repoRoot) + if err != nil { + return err + } + entries, keyed, err := parse(data) + if err != nil { + return err + } + res.Entries = countParsed(entries) + if keyed { + return nil + } + lines := strings.Split(string(data), "\n") + out := make([]string, 0, len(lines)+1) + out = append(out, privateFormatDecl) + for i, raw := range lines { + if i == 0 { + raw = strings.TrimPrefix(raw, utf8BOM) + } + line := trimLead(trimTrail(raw)) + if line == "" || strings.HasPrefix(line, "#") { + out = append(out, raw) + continue + } + key := "entry-" + strconv.Itoa(i+1) + if !composedLineRoundTrips(key, line) { + return fmt.Errorf("%w: line %d of %s does not survive keying as %s (the value is withheld); key it by hand", + ErrInvalidPattern, i+1, PrivateRelPath, key) + } + out = append(out, key+" "+line) + } + if err := writePrivateStore(repoRoot, []byte(strings.Join(out, "\n"))); err != nil { + return err + } + res.Migrated = true + return nil + }) + if err != nil { + return MigrateResult{Path: PrivateRelPath}, err + } + return res, nil +} diff --git a/internal/core/banlist/migrate_test.go b/internal/core/banlist/migrate_test.go new file mode 100644 index 000000000..16e3f9633 --- /dev/null +++ b/internal/core/banlist/migrate_test.go @@ -0,0 +1,106 @@ +package banlist + +import ( + "errors" + "strings" + "testing" +) + +// TestMigratePrivateKeysEveryLegacyLineAsTheGuardAlreadyNamesIt is the migration a +// legacy store is offered (iss-2609252007433563): the format declaration goes on +// line 1, every whole-line pattern keeps its exact bytes under the key the guard +// already prints for it (entry-<its original line>), and every comment and blank +// line survives. A second run finds a keyed store and writes nothing. +func TestMigratePrivateKeysEveryLegacyLineAsTheGuardAlreadyNamesIt(t *testing.T) { + root := t.TempDir() + legacy := "\xef\xbb\xbf# my own notes\nwidgetworks\n\t spaced out pattern \r\n\nfoo[.]bar\n" + writePrivate(t, root, legacy) + + before, keyed, err := parse([]byte(legacy)) + if err != nil || keyed { + t.Fatalf("fixture is not a legacy store: %v %v", keyed, err) + } + res, err := MigratePrivate(root) + if err != nil { + t.Fatal(err) + } + if !res.Migrated || res.Entries != 3 || res.Path != PrivateRelPath { + t.Fatalf("result = %+v", res) + } + body := readStore(t, root) + if !strings.HasPrefix(body, privateFormatDecl+"\n# my own notes\n") { + t.Fatalf("the declaration is not line 1, or the header comment was lost:\n%q", body) + } + after, keyed, err := parse([]byte(body)) + if err != nil || !keyed { + t.Fatalf("migrated store does not read as keyed: %v %v", keyed, err) + } + if len(after) != len(before) { + t.Fatalf("entries: %d before, %d after", len(before), len(after)) + } + for i := range before { + if after[i].unparsed || after[i].key != before[i].key || after[i].pattern != before[i].pattern { + t.Errorf("entry %d: before (%s), after (%s, unparsed=%v); the key or the pattern moved", + i, before[i].key, after[i].key, after[i].unparsed) + } + } + + res, err = MigratePrivate(root) + if err != nil || res.Migrated { + t.Fatalf("second migration: %+v %v", res, err) + } + if readStore(t, root) != body { + t.Fatal("a migration of a keyed store rewrote it") + } +} + +// TestMigratePrivateRefusals: there is nothing to migrate in an absent store, and a +// damaged declaration is the parser's refusal, never a migration. +func TestMigratePrivateRefusals(t *testing.T) { + if _, err := MigratePrivate(t.TempDir()); !errors.Is(err, ErrNoStore) { + t.Fatalf("absent store: %v, want ErrNoStore", err) + } + root := t.TempDir() + damaged := " " + privateFormatDecl + "\nkey widgetworks\n" + writePrivate(t, root, damaged) + if _, err := MigratePrivate(root); !errors.Is(err, ErrMalformedStore) { + t.Fatalf("damaged declaration: %v, want ErrMalformedStore", err) + } + if readStore(t, root) != damaged { + t.Fatal("a refused migration wrote the store") + } +} + +// TestLegacyRefusalNamesTheMigration: every verb that refuses a legacy store names +// the command that migrates it, rather than asking for every line to be keyed by hand. +func TestLegacyRefusalNamesTheMigration(t *testing.T) { + root := t.TempDir() + writePrivate(t, root, "widgetworks\n") + _, err := AddPrivate(AddPrivateRequest{RepoRoot: root, Key: "k", Pattern: "other"}) + if !errors.Is(err, ErrLegacyStore) || !strings.Contains(err.Error(), "abcd banlist migrate") { + t.Fatalf("legacy refusal: %v", err) + } + if strings.Contains(err.Error(), "widgetworks") { + t.Fatalf("the refusal quotes a pattern: %v", err) + } +} + +// TestTheGuardRefusesTheSameKeyBeforeAndAfterMigration drives the committed guard +// over one legacy store and its migration: the same staged text is refused, under +// the same key, both times. +func TestTheGuardRefusesTheSameKeyBeforeAndAfterMigration(t *testing.T) { + legacy := "# notes\nwidgetworks\n" + blocked, out := hookRun(t, legacy, "the widgetworks draft\n") + if !blocked || !strings.Contains(out, "entry-2") { + t.Fatalf("the legacy store does not refuse under entry-2\n%s", out) + } + root := t.TempDir() + writePrivate(t, root, legacy) + if _, err := MigratePrivate(root); err != nil { + t.Fatal(err) + } + blocked, out = hookRun(t, readStore(t, root), "the widgetworks draft\n") + if !blocked || !strings.Contains(out, "entry-2") || !strings.Contains(out, "keyed store") { + t.Fatalf("the migrated store does not refuse under entry-2 as a keyed store\n%s", out) + } +} diff --git a/internal/core/banlist/private.go b/internal/core/banlist/private.go index 4af57109f..b727683f5 100644 --- a/internal/core/banlist/private.go +++ b/internal/core/banlist/private.go @@ -601,14 +601,16 @@ func RemovePrivate(repoRoot, key string) (PrivateResult, error) { return res, nil } -// legacyStoreRefusal words the one migration a verb will not perform. Writing a -// KEY<space>PATTERN line into a legacy store would not merely add an entry: the -// next reader that sees a declaration reinterprets every OTHER line, so a store of -// whole-line patterns would silently start matching remainders. The user adds one -// line and keeps control of what their patterns mean. +// legacyStoreRefusal words the one migration a mutating verb will not perform on +// the side. Writing a KEY<space>PATTERN line into a legacy store would not merely +// add an entry: the next reader that sees a declaration reinterprets every OTHER +// line, so a store of whole-line patterns would silently start matching +// remainders. The migration is its own visible act (MigratePrivate, `abcd banlist +// migrate`), which keys every line so it keeps matching what it matched. func legacyStoreRefusal(verb string) error { return fmt.Errorf("%w: %s predates the keyed format, so every line in it is a whole-line pattern; "+ - "to %s it, add this as the file's FIRST line and give each existing line a key — %s", + "to %s it, migrate it once with `abcd banlist migrate`, which keys every line as entry-<line> and changes nothing it matches "+ + "(or add %q as its FIRST line and key each line by hand)", ErrLegacyStore, PrivateRelPath, verb, privateFormatDecl) } diff --git a/internal/surface/cli/banlist.go b/internal/surface/cli/banlist.go index 4e5b6ecf0..e968a827c 100644 --- a/internal/surface/cli/banlist.go +++ b/internal/surface/cli/banlist.go @@ -28,7 +28,7 @@ func newBanlistCommand(asJSON *bool) *cobra.Command { // withholds the token and names the real subcommands instead. Args: func(_ *cobra.Command, args []string) error { if len(args) > 0 { - return fmt.Errorf(`unknown subcommand (its text is withheld — it may be a private value); use "list", "add", or "remove"`) + return fmt.Errorf(`unknown subcommand (its text is withheld — it may be a private value); use "list", "add", "remove", or "migrate"`) } return nil }, @@ -56,9 +56,45 @@ func newBanlistCommand(asJSON *bool) *cobra.Command { banlistCmd.AddCommand(newBanlistListCommand(asJSON)) banlistCmd.AddCommand(newBanlistAddCommand(asJSON)) banlistCmd.AddCommand(newBanlistRemoveCommand(asJSON)) + banlistCmd.AddCommand(newBanlistMigrateCommand(asJSON)) return banlistCmd } +// newBanlistMigrateCommand converts a legacy private store to the keyed format in +// place. It takes no layer flag: only the private layer has a legacy format. +func newBanlistMigrateCommand(asJSON *bool) *cobra.Command { + return &cobra.Command{ + Use: "migrate", + Short: "Key a legacy private store in place (every line keeps matching what it matched)", + Long: "Convert a legacy private store (" + banlist.PrivateRelPath + " with no\n" + + "'# abcd-banlist: keyed' first line, every line a whole-line pattern) to the keyed\n" + + "format: the declaration becomes line 1, and each pattern keeps its exact bytes under\n" + + "the key the guard already names it by, entry-<its line>. Comments and blank lines\n" + + "survive. add, remove and `abcd source sync-banlist` refuse a legacy store with entries\n" + + "until it is migrated. A keyed store is left alone. No pattern is printed.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + root, err := banlistRoot(cmd.ErrOrStderr()) + if err != nil { + return usageError("abcd banlist migrate", err) + } + res, err := banlist.MigratePrivate(root) + if err != nil { + return usageError("abcd banlist migrate", err) + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + if res.Migrated { + fmt.Fprintf(w, "private banlist — migrated to the keyed format (%d entr%s keyed as entry-<line>, %s)\n", + res.Entries, plural(res.Entries), res.Path) + return + } + fmt.Fprintf(w, "private banlist — already keyed, nothing to migrate (%d entr%s, %s)\n", + res.Entries, plural(res.Entries), res.Path) + }) + }, + } +} + // newBanlistListCommand is the explicit read verb. Unscoped it renders both layers // (the same result the bare command renders); a layer flag scopes it to one. func newBanlistListCommand(asJSON *bool) *cobra.Command { diff --git a/internal/surface/cli/banlist_surface_test.go b/internal/surface/cli/banlist_surface_test.go index 088329da0..fdfbdca36 100644 --- a/internal/surface/cli/banlist_surface_test.go +++ b/internal/surface/cli/banlist_surface_test.go @@ -514,3 +514,41 @@ func TestBanlistAcceptsATrailingJSONFlag(t *testing.T) { t.Errorf("result = %+v", res) } } + +// TestBanlistMigrateKeysALegacyStore is the front door onto the legacy-store +// migration (iss-2609252007433563): the refusal `add` gives a legacy store names +// `abcd banlist migrate`, the migration reports its count and never a pattern, and +// the `add` it unblocks then succeeds. A second run says there was nothing to do. +func TestBanlistMigrateKeysALegacyStore(t *testing.T) { + repo := blRepo(t, "") + store := filepath.Join(repo, filepath.FromSlash(banlist.PrivateRelPath)) + if err := os.MkdirAll(filepath.Dir(store), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store, []byte("# notes\nwidgetworks\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + stdout, stderr, code := runBanlist("", "banlist", "add", "--private", "k2", "other") + if code != 2 || !strings.Contains(stdout+stderr, "abcd banlist migrate") { + t.Fatalf("add over a legacy store: exit %d\n%s%s", code, stdout, stderr) + } + stdout, stderr, code = runBanlist("", "banlist", "migrate") + if code != 0 || !strings.Contains(stdout, "1 entr") || !strings.Contains(stdout, "keyed") { + t.Fatalf("migrate: exit %d\n%s%s", code, stdout, stderr) + } + if strings.Contains(stdout+stderr, "widgetworks") { + t.Fatalf("the migration echoes a pattern:\n%s%s", stdout, stderr) + } + if body, _ := os.ReadFile(store); !strings.HasPrefix(string(body), blFormatDecl+"\n") || !strings.Contains(string(body), "entry-2 widgetworks") { + t.Fatalf("store after migration:\n%s", body) + } + if _, _, code := runBanlist("", "banlist", "add", "--private", "k2", "other"); code != 0 { + t.Fatalf("add after migration: exit %d", code) + } + stdout, stderr, code = runBanlist("", "banlist", "migrate", "--json") + if code != 0 || !strings.Contains(stdout, `"migrated": false`) { + t.Fatalf("second migrate: exit %d\n%s%s", code, stdout, stderr) + } +} diff --git a/internal/surface/cli/source.go b/internal/surface/cli/source.go index c84358067..44c5f0ac7 100644 --- a/internal/surface/cli/source.go +++ b/internal/surface/cli/source.go @@ -436,7 +436,8 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman "whitespace-flexible, case-insensitive phrases. Lines outside the block survive.\n" + "A corpus whose folders and entries disagree is refused and nothing is written.\n\n" + "--refresh is the pre-commit guard's mode: it updates a private store that already\n" + - "exists and never creates one. With no corpus, or no store, it says so on one line and\n" + + "exists and declares the keyed format, and never creates one. With no corpus, no store\n" + + "or a legacy store (migrate it with `abcd banlist migrate`) it says so on one line and\n" + "exits 0.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -463,6 +464,11 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman banlist.PrivateRelPath) return nil } + if refresh && errors.Is(err, banlist.ErrLegacyStore) { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: %s predates the keyed format — migrate it once with `abcd banlist migrate` (not refreshed)\n", + banlist.PrivateRelPath) + return nil + } if err != nil { return sourceError("sync-banlist", dir, err) } diff --git a/internal/surface/cli/source_surface_test.go b/internal/surface/cli/source_surface_test.go index d7e69bb6d..0372bc444 100644 --- a/internal/surface/cli/source_surface_test.go +++ b/internal/surface/cli/source_surface_test.go @@ -228,3 +228,25 @@ func TestSourceRefreshNeverCreatesTheStore(t *testing.T) { t.Fatalf("refresh of an existing store: %d %s%s", code, out, errs) } } + +// TestSourceRefreshNamesTheMigrationForALegacyStore: a legacy store is not refreshed, +// and the refresh says so on one line naming the command that migrates it, with exit +// 0 so the guard can relay it without calling it a failure. The by-hand sync refuses +// and names the same command. Neither writes the store. +func TestSourceRefreshNamesTheMigrationForALegacyStore(t *testing.T) { + _, repo := sourceCheckout(t) + confidentialCorpus(t) + legacy := "# notes\nwidgetworks\n" + writeRel(t, repo, ".abcd/.work.local/private-names.txt", legacy) + code, stdout, stderr := runSource(t, "sync-banlist", "--refresh") + if code != 0 || strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "abcd banlist migrate") { + t.Fatalf("refresh over a legacy store: exit %d\n%s%s", code, stdout, stderr) + } + code, stdout, stderr = runSource(t, "sync-banlist") + if code != 2 || !strings.Contains(stdout+stderr, "abcd banlist migrate") { + t.Fatalf("by-hand sync over a legacy store: exit %d\n%s%s", code, stdout, stderr) + } + if body, _ := os.ReadFile(filepath.Join(repo, ".abcd", ".work.local", "private-names.txt")); string(body) != legacy { + t.Fatalf("a legacy store was written:\n%s", body) + } +} From a5510f1b6832e9596faa9eb3e202fd68f2c91f1b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:19:50 +0100 Subject: [PATCH 010/107] fix(source): cite-check states its offset as a text offset, everywhere A finding rendered as "line 2, byte 12" where 12 is grep -b's offset from the start of the whole text, taken at the start of the matched span, which can be the boundary byte before the phrase: a reader takes it for a column in line 2. Of the two remedies the review offered, the offset is documented as what it is rather than recomputed: telling the boundary byte from a phrase that itself begins with punctuation would need a second matcher, and the scan's contract is that there is one. The human line now reads "line 2, at byte 12 of the text", and Hit, Finding, the verb's help, the command page and the surface chapter say the same thing. The core test pins the exact value instead of a range. Refs: iss-2609252007434356 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/31-source.md | 6 +++-- commands/source.md | 4 +++- docs/reference/cli/commands.md | 4 +++- internal/core/banlist/generated.go | 13 +++++++---- internal/core/banlist/generated_test.go | 7 ++++-- internal/core/source/guard.go | 8 +++++-- internal/surface/cli/source.go | 6 +++-- internal/surface/cli/source_surface_test.go | 23 +++++++++++++++++++ 8 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/31-source.md b/.abcd/development/brief/04-surfaces/31-source.md index 691af4740..41981b912 100644 --- a/.abcd/development/brief/04-surfaces/31-source.md +++ b/.abcd/development/brief/04-surfaces/31-source.md @@ -132,8 +132,10 @@ ban ordinary words. The scan reads a file or stdin through the same projection and the same engine as the guard, so a text it calls clean is a text the guard would pass. It reports each finding by key, field (`title`, `alias-N`, `author-N`), line and -byte offset, and never by the text matched, so its report is safe to relay. It -exits 1 when anything is found. +byte offset, and never by the text matched, so its report is safe to relay. The +offset is the guard engine's own: it counts from the start of the whole text, +not from the start of the line, to the start of the matched span, which can be +the one byte before the phrase that bounds it. It exits 1 when anything is found. ## Absence is loud diff --git a/commands/source.md b/commands/source.md index a30135f34..ecb350996 100644 --- a/commands/source.md +++ b/commands/source.md @@ -99,7 +99,9 @@ anywhere git does not gate: Exit 1 means a finding: each names the source's key, the field (`title`, `alias-N`, `author-N`), the line and the byte offset — never the matched text, so -the report is safe to relay. Reword generically and scan again. A clean scan +the report is safe to relay. The offset counts from the start of the whole text, +not from the start of the line, and it can point at the one byte before the +phrase that bounds it. Reword generically and scan again. A clean scan covers literal strings only, never an identifying paraphrase. ## Declassify a published source diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7fe69da19..8c6588e2c 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1606,7 +1606,9 @@ Scan text for confidential sources; report offenders by key only (exit 1 on a hi Scan a file, or stdin with -, for every confidential source's title, aliases and opted-in authors, through the private banlist's matcher — the engine the pre-commit guard runs. Offenders are reported by key, field, line and byte offset, never by the -text matched, so the report is safe to relay. Exit 1 when anything is found. +text matched, so the report is safe to relay. The offset counts bytes from the start +of the whole text, not from the start of the line, to the start of the matched span, +which can be the one byte before the phrase that bounds it. Exit 1 when anything is found. #### `abcd source declassify` diff --git a/internal/core/banlist/generated.go b/internal/core/banlist/generated.go index fbf74e648..272c853ff 100644 --- a/internal/core/banlist/generated.go +++ b/internal/core/banlist/generated.go @@ -33,10 +33,13 @@ type KeyedPattern struct { Pattern string } -// Hit is one match found by ScanText: the key, the 1-based line, and the byte -// offset of the matched span in the scanned text. The span may begin one byte -// before the phrase itself, where the leading boundary consumed a non-alphanumeric -// neighbour. There is deliberately no field for the matched text: the type is the +// Hit is one match found by ScanText: the key, the 1-based line, and the 0-based +// byte offset of the start of the matched span, counted from the start of the WHOLE +// scanned text — grep -b's file offset, never a column in Line. The span may begin +// one byte before the phrase itself, where the leading boundary consumed a +// non-alphanumeric neighbour; which byte it is cannot be told apart from a phrase +// that itself begins with punctuation without a second matcher, and there is one +// matcher. There is deliberately no field for the matched text: the type is the // redaction, so a scan's report is safe to relay. type Hit struct { Key string `json:"key"` @@ -172,7 +175,7 @@ func foldOrbit(r rune) string { } // ScanText reports every place the given patterns match text, by key, line and -// byte offset, through the guard's own engine: `grep -inobaE` under LC_ALL=C, each +// text offset (see Hit), through the guard's own engine: `grep -inobaE` under LC_ALL=C, each // pattern on STDIN (never argv), against the text in a 0600 temporary file removed // afterwards. It is the read-only twin of the pre-commit guard, so a text this scan // calls clean is a text the guard would pass, line for line. diff --git a/internal/core/banlist/generated_test.go b/internal/core/banlist/generated_test.go index 4f3d43629..c3fe005e9 100644 --- a/internal/core/banlist/generated_test.go +++ b/internal/core/banlist/generated_test.go @@ -128,8 +128,11 @@ func TestScanTextReportsKeysAndOffsetsOnly(t *testing.T) { if len(hits) != 1 || hits[0].Key != "sources/k1/title" || hits[0].Line != 2 { t.Fatalf("hits = %+v", hits) } - if hits[0].Offset < 9 || hits[0].Offset > 13 { - t.Errorf("offset %d is not on line 2's match", hits[0].Offset) + // The offset is counted from the start of the whole text, and the span starts + // at the boundary byte the leading neighbour test consumed: "line one\n" is 9 + // bytes and "see" 3 more, so the space before the phrase is byte 12. + if hits[0].Offset != 12 { + t.Errorf("offset %d, want 12 (the text offset of the span, boundary byte included)", hits[0].Offset) } } diff --git a/internal/core/source/guard.go b/internal/core/source/guard.go index 5a905e2bb..82d363eac 100644 --- a/internal/core/source/guard.go +++ b/internal/core/source/guard.go @@ -91,8 +91,12 @@ func SyncBanlist(corpus, repoRoot string, opts SyncOptions) (SyncResult, error) type Finding struct { Source string `json:"source"` Field string `json:"field"` - Line int `json:"line"` - Offset int `json:"offset"` + // Line is the 1-based line the match is on. + Line int `json:"line"` + // Offset is banlist.Hit's: the 0-based byte offset from the start of the WHOLE + // scanned text (never a column in Line) to the start of the matched span, which + // can be the one boundary byte before the phrase. + Offset int `json:"offset"` } // CiteReport is a cite-check's outcome. diff --git a/internal/surface/cli/source.go b/internal/surface/cli/source.go index 44c5f0ac7..4f7aa764f 100644 --- a/internal/surface/cli/source.go +++ b/internal/surface/cli/source.go @@ -496,7 +496,9 @@ func newSourceCiteCheckCommand(asJSON *bool, corpusFlag *string) *cobra.Command Long: "Scan a file, or stdin with -, for every confidential source's title, aliases and\n" + "opted-in authors, through the private banlist's matcher — the engine the pre-commit\n" + "guard runs. Offenders are reported by key, field, line and byte offset, never by the\n" + - "text matched, so the report is safe to relay. Exit 1 when anything is found.", + "text matched, so the report is safe to relay. The offset counts bytes from the start\n" + + "of the whole text, not from the start of the line, to the start of the matched span,\n" + + "which can be the one byte before the phrase that bounds it. Exit 1 when anything is found.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { dir, err := sourceCorpusDir(*corpusFlag) @@ -526,7 +528,7 @@ func newSourceCiteCheckCommand(asJSON *bool, corpusFlag *string) *cobra.Command } fmt.Fprintf(w, "abcd source cite-check — %d finding%s (the matched text is withheld)\n", len(rep.Findings), pluralS(len(rep.Findings))) for _, f := range rep.Findings { - fmt.Fprintf(w, " %s %s line %d, byte %d\n", f.Source, f.Field, f.Line, f.Offset) + fmt.Fprintf(w, " %s %s line %d, at byte %d of the text\n", f.Source, f.Field, f.Line, f.Offset) } }); rerr != nil { return rerr diff --git a/internal/surface/cli/source_surface_test.go b/internal/surface/cli/source_surface_test.go index 0372bc444..d70f48305 100644 --- a/internal/surface/cli/source_surface_test.go +++ b/internal/surface/cli/source_surface_test.go @@ -250,3 +250,26 @@ func TestSourceRefreshNamesTheMigrationForALegacyStore(t *testing.T) { t.Fatalf("a legacy store was written:\n%s", body) } } + +// TestSourceCiteCheckStatesTheOffsetAsATextOffset (iss-2609252007434356): the +// offset is grep -b's, counted from the start of the scanned text and taken at the +// start of the matched span, which can be the boundary byte before the phrase. A +// finding on line 2 must not read as a column in line 2: the human line says what +// the number counts from, and the JSON carries the same number. +func TestSourceCiteCheckStatesTheOffsetAsATextOffset(t *testing.T) { + sourceCheckout(t) + confidentialCorpus(t) + text := "line one\nsee Quiet Harbour Working Notes\n" + var stdin bytes.Buffer + stdin.WriteString(text) + code, out, errs := runSourceStdin(t, &stdin, "cite-check", "-") + if code != 1 || !strings.Contains(out, "line 2, at byte 12 of the text") { + t.Fatalf("cite-check: exit %d\n%s%s", code, out, errs) + } + stdin.Reset() + stdin.WriteString(text) + code, out, errs = runSourceStdin(t, &stdin, "cite-check", "-", "--json") + if code != 1 || !strings.Contains(out, `"offset": 12`) { + t.Fatalf("cite-check --json: exit %d\n%s%s", code, out, errs) + } +} From 6a87478696a3340b6dab134efddc4edbce2e02ec Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:19:58 +0100 Subject: [PATCH 011/107] =?UTF-8?q?chore:=20resolve=20iss-2609252007434356?= =?UTF-8?q?=20=E2=80=94=20cite-check's=20offset=20reads=20as=20a=20text=20?= =?UTF-8?q?offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252007434356 Assisted-by: Claude:claude-opus-5-5 --- ...ource-cite-check-reports-a-finding-as-line-l-byte-b.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md (56%) diff --git a/.abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md b/.abcd/work/issues/resolved/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md similarity index 56% rename from .abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md rename to .abcd/work/issues/resolved/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md index 003b424b9..a17ae4271 100644 --- a/.abcd/work/issues/open/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md +++ b/.abcd/work/issues/resolved/iss-2609252007434356-abcd-source-cite-check-reports-a-finding-as-line-l-byte-b.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/source/guard.go" +resolution: "cite-check renders the offset as a text offset ('line L, at byte B of the text') and Hit, Finding, the help, the command page and the surface chapter document it as the start of the matched span counted from the start of the whole text" +impact: fix +resolved_by: + commit: "a5510f1b" --- abcd source cite-check reports a finding as 'line L, byte B' where B is grep -b's FILE offset of the matched span, which also includes the leading boundary byte, so a reader takes it for a column in line L. The offset must be documented and rendered as what it is, consistently in the type, the help and the surface pages. + +## Grounds + +- pursued: a reader of a cite-check finding locates it by counting bytes from the start of the text; a finding whose number is a column, or a surface that still calls it a line position, would show it wrong From 46af066199a8ce8f54054ae7236f2e83e5758682 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:27:48 +0100 Subject: [PATCH 012/107] fix(hooks): the guard's sources refresh builds or is opted into, and says what it did The pre-commit guard's refresh of the sources banlist block resolved an installed abcd (the pinned PATH, then ~/.local/bin) in both copies, threw the binary's output away, and created the private store when it had none. Per the review's safe shape, adopted by the orchestrator: - This repository's copy never resolves an installed binary. It builds ./cmd/abcd from the checkout the way .githooks/commit-msg does (go resolved on the inherited PATH before the pin, -overlay/-toolexec in GOFLAGS refused, git's environment kept from the build) and runs that. A tree that does not build is one warning line and the commit proceeds. - The template `abcd ahoy` scaffolds refreshes only on repo-local opt-in: `git config --local abcd.sourcesBinary <absolute path>`, an executable regular file, with no PATH search and never an environment variable. Unset, or unusable, is one line and the commit proceeds. This decides none of the three questions the product thinker's ruling holds for a scaffolded hook (iss-2609250834251447, left open); the ruling can widen it, and .abcd/work/DECISIONS.md says so. - Both relay the binary's own output instead of discarding it, so the one-line count shows a refresh that wrote fewer patterns; a binary whose help names no --refresh is one remedy line, never "refresh failed". - Both run the refresh only over a store that already exists and declares the keyed format, after the tampering refusals and before the read; no store is one line, and a legacy store is named with `abcd banlist migrate` once per working tree (an O_EXCL marker in the local tier), since the store's own format line already says legacy on every commit. Tests: every guard case runs against both copies; this repository's copy builds a fake ./cmd/abcd in the throwaway repo, and one end-to-end test drives the real .githooks through a real build and refresh, watched red against the previous hook on a scratch copy. Refs: iss-2609252007414882 Refs: iss-2609252007419997 Refs: iss-2609252007422630 Refs: iss-2609252007426016 Refs: iss-2609252007433563 Refs: iss-2609250834251447 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/13-consult.md | 3 +- .../brief/04-surfaces/31-source.md | 35 +- .abcd/work/DECISIONS.md | 1 + .githooks/pre-commit | 181 ++++++++-- commands/consult.md | 15 +- commands/ingest.md | 4 +- commands/source.md | 9 +- internal/core/ahoy/defaults/pre-commit | 133 ++++++-- internal/core/banlist/hook_sources_test.go | 311 ++++++++++++++++-- internal/core/source/source_test.go | 68 ++++ 10 files changed, 645 insertions(+), 115 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/13-consult.md b/.abcd/development/brief/04-surfaces/13-consult.md index 302b38c3b..db57f4c00 100644 --- a/.abcd/development/brief/04-surfaces/13-consult.md +++ b/.abcd/development/brief/04-surfaces/13-consult.md @@ -83,7 +83,8 @@ discussed freely there. The rule is backed mechanically rather than trusted alone, by the `abcd source` verbs. `sync-banlist` maintains a generated block in the repo's untracked `.abcd/.work.local/private-names.txt`, which the repo's committed pre-commit guard -refreshes on every commit and then enforces. `cite-check` scans a document before +refreshes on every commit, where that store already exists and the guard can run +the verb (see [`31-source.md`](31-source.md)), and then enforces. `cite-check` scans a document before it is shared and exits non-zero when a confidential identifier is present, naming only the key, so the report itself is safe to relay. Both read the same projection of the corpus through the same matcher as the guard, so a scan and a diff --git a/.abcd/development/brief/04-surfaces/31-source.md b/.abcd/development/brief/04-surfaces/31-source.md index 41981b912..53369dc38 100644 --- a/.abcd/development/brief/04-surfaces/31-source.md +++ b/.abcd/development/brief/04-surfaces/31-source.md @@ -114,10 +114,30 @@ the store when there is something to ban; the refresh mode updates a store that already exists and declares the keyed format, and never creates one, because a store created by a commit's side effect would write every confidential title into a repository's local tier without the person asking. The committed pre-commit -guard, the copy this repository runs and the one `abcd ahoy` scaffolds alike, -runs the sync in its refresh mode before it reads the store, so the block is -never more than one commit stale, and then refuses a staged commit carrying a -banned string by key. +guard runs the sync in its refresh mode before it reads the store, so the block +is never more than one commit stale, and then refuses a staged commit carrying a +banned string by key. The refresh's own line is relayed on the commit, never +discarded: its count is how a refresh that wrote fewer patterns than the last +one shows itself. + +The two copies of the guard find the binary differently. The copy this +repository runs builds `./cmd/abcd` from the checkout, as its commit-msg hook +does, and never runs an installed abcd, which in a source checkout is the last +release and may lack the verb entirely; a tree that does not build is one +warning line. The copy `abcd ahoy` scaffolds refreshes only on opt-in, per +clone: the repo-local git setting `abcd.sourcesBinary` names an absolute path +to the binary, and nothing else is consulted, neither `PATH` nor an +environment variable. How a scaffolded hook finds abcd, and whether it does so +by default, is a ruling owed to the product thinker +(iss-2609250834251447); opt-in decides none of it, and the ruling can widen +it. Without the setting, AC3 holds in this repository and in a managed +repository that opted in, and the scaffolded guard names the setting on one +line. + +A legacy private store is not refreshed. The guard names the banlist verb's +migration ([`20-banlist.md`](20-banlist.md)) on the first commit that meets it +and not again, since the store's format line already says "legacy" on every +commit. Each phrase is projected into the pattern language the guard enforces: literal, case-insensitive, and whitespace-flexible across ASCII and Unicode space @@ -143,9 +163,10 @@ With no corpus at the configured location every verb but the creating one says so on one line and exits 3, a code distinct from a refusal, so a script can tell "nothing to check against" from "checked and clean". The guard says so on one line and lets the commit proceed; the sync's refresh mode, which the guard runs, does -the same and exits 0, as it does for a repository with no private store. A corpus the guard cannot refresh (no binary found, or a -refresh that fails) is named on the commit, and the store is checked as it -stands. +the same and exits 0, as it does for a repository with no private store. A +corpus the guard cannot refresh (no binary built or opted in, a binary without +the verb, or a refresh that fails) is one line on the commit, and the store is +checked as it stands. ## What it cannot enforce diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 29d80a4a8..c55a47636 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2546,3 +2546,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-25 — Autonomous run A defers every open capture routed to the product thinker out loud to v0.10.0, under the product thinker's directive of 2026-09-25 ("I want the ledger drained": a capture ends fixed, wontfix with its reason, closed as a duplicate, or deferred out loud where it needs a product-thinker ruling or a planning interview). The product thinker is away, so no one in the run can give those rulings. 190 records each carry `deferred_after: "v0.10.0"` and a `deferral_reason` that quotes the ruling owed verbatim. 184 of them renew a v0.9.0 grant that lapsed when v0.10.0 re-anchored, and 6 carried none. Every question is asked once in the run's rulings-owed list, grouped under the routing pass's eleven themes: A, planning interviews already ruled "plan next cycle" (27); B, confirmations owed on rulings already given (8); C, dependency and publish sign-offs (6); D, narrowing a shipped promise (4); E, principles and conventions to adopt (23); F, record schema and lint rules (34); G, security and trust design forks (16); H, autonomous runs, implement and multi-agent planning (22); I, site, docs voice and product story (17); J, future capabilities to plan or close (27); K, parked on a trigger, or a human act outside the tree (6). Within each theme the questions covering a major record come first, and the list is the agenda for the next interview. The same pass closes 9 duplicates and 44 captures on their recorded merits, so none of those is deferred (implementer of lane records1). - 2026-09-25 — iss-2608291814575788 (the opt-in gitleaks augmentation reaches `history.Capture` alone) is answered by inverting the import edge, not by folding the adapter into `internal/adapter/scanner` as the record proposes: `internal/adapter/gitleaks` imports the scanner for its `Finding` type, so the fold is an import cycle. The scanner declares `type Augmenter interface { Available() error; Scan(text, file string) []Finding }` and an option `WithAugmenter(Augmenter)`; the gitleaks adapter keeps importing the scanner and is wired at the composition root (`cmd/abcd`, or the core constructor that already reads the opt-in), so the scanner never imports gitleaks. `ScanText` and `ScanBundle` append the augmenter's findings and deduplicate on file, line and span. `ErrConfiguredNotFound` becomes one state with one consequence per consumer: launch fails closed (an `Unscanned` reason, and `HardFails` incremented), while capture, history and memory write and record the gap in their receipt. It is built in its own lane of about 150 lines, not in the scanner-cluster fix round, so the record carries a v0.10.0 deferral naming this ruling. Technical ruling by the orchestrator of autonomous run A, on the design the scanner-cluster review set out (its item 4); recorded by the implementer of lane scanner, fix round 1. - 2026-09-25 — On the auto-release path the tag is still made before the `release` environment's approval, and that residual is recorded for the next cycle rather than built now (finding F2 of the workflows-lane review, LOW). release.yml's `tag` job needs `verify`, so a refused gate leaves no tag (iss-2608231226347380), but the job runs before the publish job waits on its human approval and before the four steps between the tag and `gh release create` (the vcs stamp, the archive re-verify, the two attestations). A rejected approval or a red step there leaves a tag with no Release, which the heal path then rebuilds on the next push. The closure shape: delete the `tag` job and create the tag as a step inside the `release` job, immediately before `gh release create`, through `gh api -X POST repos/<owner>/<repo>/git/refs -f ref=refs/tags/<tag> -f sha=<commit>` with the job's own `GH_TOKEN`, so it needs neither `persist-credentials` nor an extra job; a rejection or a red publish step then leaves no tag either. It also removes the auto-path half of iss-2609251125599536: once no auto-path failure can leave a tag without a Release, the only such tag is a hand-pushed one, which `detect` already refuses to rebuild when its own verify failed. Not built in this lane because the chain is unverifiable without a live release, and the order the lane shipped is the one the review walked (implementer of the workflows fix round). +- 2026-09-25 — The pre-commit guard's sources refresh (itd-76) finds its binary two ways until the product thinker rules on iss-2609250834251447, and the ruling may widen the second (finding 1 of the sources-lane review, adopted by the orchestrator as the safe shape). This repository's copy builds `./cmd/abcd` from the checkout, as `.githooks/commit-msg` does, and never runs an installed abcd; a tree that does not build is one warning line and the commit proceeds. The copy `abcd ahoy` scaffolds refreshes only when the clone opts in with `git config --local abcd.sourcesBinary <absolute path>`, a path to an executable regular file with no PATH search and never an environment variable, and otherwise prints one line and proceeds. That answers none of the three questions the ruling holds (how a scaffolded hook finds abcd, fail open or closed, default or opt-in): it is the shape that leaves them open, so AC3 holds in this repository and in a managed repository that opted in. In both copies the refresh updates an existing keyed private store only, relays the binary's own line, and names a legacy store's migration once per working tree (implementer of the sources fix round). diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f34ce6e65..ce28458e7 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -73,8 +73,10 @@ case $- in *x*) set +x ;; esac # builtins this hook steers on; a shadowed `continue` or `break` changes which lines # a loop reads or never ends it, so they are pinned with the tools (parity with # .githooks/commit-msg, where a no-op `continue` once cut the judged message to nothing). +# `go`, `cd`, `pwd` and `trap` are on it for the sources refresh below, which builds +# this checkout's abcd (parity with .githooks/commit-msg). unset -f git grep mktemp tr cat mkdir rm chmod printf sed head command read echo exit test [ \ - declare continue break return local export set true 2>/dev/null || true + declare continue break return local export set true go cd pwd trap 2>/dev/null || true # A field separator the caller cannot choose: an inherited IFS changes how every # unquoted expansion below splits — including the sweep loop's, so it is pinned # before the sweep runs. @@ -100,6 +102,19 @@ unset _fn 2>/dev/null || true # committer's environment. The Go parser pins the same ASCII-only reading. LC_ALL=C export LC_ALL +# The Go toolchain for the sources refresh below, which builds this checkout's own +# abcd, is resolved on the INHERITED PATH, to an absolute path, before the pin: it +# lives wherever its installer put it. The same deliberate boundary as +# .githooks/commit-msg draws, and it takes the Go environment with it (GOFLAGS, +# GOROOT, GOTOOLCHAIN, GOCACHE are the committer's). The refresh only ever WIDENS +# what this guard refuses from a store it already has, and a missing toolchain is +# one warning line, never a refusal. +go_bin=$(command -v go 2>/dev/null || true) +case "$go_bin" in /*) ;; *) go_bin="" ;; esac +# Where this hook was committed, resolved before the directory change below: its +# parent is the checkout whose ./cmd/abcd the refresh builds. +hook_dir="${0%/*}" +case "$hook_dir" in /*) ;; *) hook_dir="$PWD/$hook_dir" ;; esac # PATH is pinned to the standard system directories. A repo-scoped PATH prepend — a # direnv file, an editor task, a shell rc a repo asked to be sourced — shipping a # fake `grep` that always exits 1 would leave this guard announcing its entry count @@ -396,40 +411,6 @@ refuse_store_copy() { fi } -# --- itd-76: refresh the sources corpus's generated block ---------------------- -# The user-level sources corpus (`abcd source`) projects every confidential -# source's title and aliases (and, where an entry opts in, its authors) into a -# generated block of the private store below. The block is refreshed HERE, before -# the store is read, so the check is never more than this commit stale. -# -# Never a failure, never silent. No corpus is one line and the commit proceeds — -# CI, a fresh clone, and every machine that never made a corpus take that branch. -# A corpus with no abcd binary to refresh it, or a refresh that fails, is one line -# and the check runs against the store as it stands (the block already written -# keeps banning). The binary's own output is discarded: its refusals name keys -# only, and `abcd source sync-banlist` run by hand shows them. -# -# The binary is looked up on the pinned PATH (plus abcd.guardPath), then at the -# user-level install location ~/.local/bin, which is user-owned and not -# repository-scoped. The corpus location is the default one; relocating the -# user-level home is a separate design (itd-77). -sources_corpus="${HOME:-}/.abcd/sources" -if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then - echo "pre-commit: no sources corpus at ~/.abcd/sources — generated banlist block not refreshed (skipped)" >&2 -else - abcd_bin=$(command -v abcd 2>/dev/null || true) - if [ -z "$abcd_bin" ] && [ -x "$HOME/.local/bin/abcd" ]; then - abcd_bin="$HOME/.local/bin/abcd" - fi - if [ -z "$abcd_bin" ]; then - echo "pre-commit: warning — a sources corpus exists but no abcd binary is on the guard's PATH or in ~/.local/bin;" >&2 - echo " the generated banlist block was not refreshed, so the banlist is checked as it stands." >&2 - elif ! "$abcd_bin" source sync-banlist --refresh >/dev/null 2>&1; then - echo "pre-commit: warning — the sources banlist refresh failed (run 'abcd source sync-banlist' to see why);" >&2 - echo " the banlist is checked as it stands." >&2 - fi -fi - # A path that exists but is not a REGULAR FILE is tampering, not non-adoption: a # symlink (which `git add -f` can commit, so a checkout materialises it) swaps or # empties the guard, and a directory or FIFO there would take the "absent" branch @@ -469,6 +450,136 @@ if [ -n "$primary_banlist" ]; then refuse_non_regular_store "$primary_banlist" "$primary_dir" "$primary_label" fi +# --- itd-76: refresh the sources corpus's generated block ---------------------- +# The user-level sources corpus (`abcd source`) projects every confidential +# source's title and aliases (and, where an entry opts in, its authors) into a +# generated block of this working tree's private store. The block is refreshed +# HERE, after the store has passed the tampering refusals above and before it is +# read, so the check is never more than this commit stale. +# +# Never a failure, never silent, and never a store this commit did not already +# have. Each branch below is ONE line and the commit proceeds: +# * no corpus — CI, a fresh clone, every machine that never made one; +# * no private store in this working tree — the refresh updates a store, it never +# creates one, because creating it writes every confidential title into this +# repository's local tier, which is the person's act (`abcd source +# sync-banlist`, by hand), not a side effect of a commit; +# * a legacy store — named with its migration on the first commit that meets it +# and not again, because a line printed on every commit is a line the +# committer learns to skip (the store's own format line below says "legacy" on +# every commit regardless); +# * a tree that does not build. +# Otherwise the refresh runs and its own output is RELAYED, never discarded: its +# one-line count is how a refresh that wrote fewer patterns than the last one +# becomes visible, and its refusals name keys only. +# +# The binary is THIS checkout's own ./cmd/abcd, built the way .githooks/commit-msg +# builds it, and never an installed abcd: in a source checkout an installed abcd is +# whatever was last released, stale by construction and possibly without the +# `source` verb at all. The corpus location is the default one; relocating the +# user-level home is a separate design (itd-77). +sources_corpus="${HOME:-}/.abcd/sources" +sources_noticed="$banlist_dir/sources-legacy-noticed" + +# sources_relay runs one refresh binary and relays what it says, prefixed. A binary +# that has no `source sync-banlist --refresh` is ONE line naming the remedy, not a +# failure, and its own error text is not relayed. +sources_relay() { + _bin="$1" + _what="$2" + _rc=0 + _out=$(unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_PREFIX + "$_bin" source sync-banlist --refresh 2>&1) || _rc=$? + if [ "$_rc" -ne 0 ]; then + _help=$("$_bin" source sync-banlist --help 2>&1 || true) + case $_help in + *--refresh*) ;; + *) + echo "pre-commit: $_what has no 'source sync-banlist --refresh', so the sources banlist block was not refreshed (skipped)" >&2 + return 0 + ;; + esac + fi + while IFS= read -r _line || [ -n "$_line" ]; do + if [ -n "$_line" ]; then + printf 'pre-commit: %s\n' "$_line" >&2 + fi + done <<<"$_out" + if [ "$_rc" -ne 0 ]; then + echo "pre-commit: warning — the sources banlist refresh failed; the banlist is checked as it stands." >&2 + fi +} + +# sources_refresh builds this checkout's abcd into a scratch directory and runs the +# refresh with it. Every way the build cannot happen is one warning line. +sources_refresh() { + _src="" + for _cand in "$hook_dir/.." "$toplevel"; do + if [ -f "$_cand/go.mod" ] && [ -d "$_cand/cmd/abcd" ]; then + _src="$(cd "$_cand" && pwd)" + break + fi + done + if [ -z "$_src" ]; then + echo "pre-commit: warning — no abcd source (./cmd/abcd) beside this hook or in this working tree to build the sources refresh from; the banlist is checked as it stands." >&2 + return 0 + fi + if [ -z "$go_bin" ]; then + echo "pre-commit: warning — go is not on PATH, so ./cmd/abcd could not be built for the sources refresh; the banlist is checked as it stands." >&2 + return 0 + fi + # -overlay swaps source files and -toolexec runs every compile step through + # another program: with either, the build is not this checkout's source, and its + # binary is not run. go splits GOFLAGS on blanks and quotes; so does this. + _goflags=$(cd "$_src" && "$go_bin" env GOFLAGS 2>/dev/null) || _goflags="-toolexec" + _goflags=${_goflags//[\"\']/ } + _goflags=${_goflags//$'\r'/ } + set -f + for _flag in $_goflags; do + case "$_flag" in + -overlay | -overlay=* | --overlay | --overlay=* | -toolexec | -toolexec=* | --toolexec | --toolexec=*) + set +f + echo "pre-commit: warning — could not build ./cmd/abcd from this checkout for the sources refresh (GOFLAGS carries -overlay or -toolexec, or go env failed); the banlist is checked as it stands." >&2 + return 0 + ;; + esac + done + set +f + _work=$(mktemp -d "${TMPDIR:-/tmp}/abcd-pre-commit.XXXXXX") || { + echo "pre-commit: warning — could not create a scratch directory to build ./cmd/abcd for the sources refresh; the banlist is checked as it stands." >&2 + return 0 + } + trap 'rm -rf "$_work"' EXIT INT TERM HUP + if (cd "$_src" && unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_PREFIX && + "$go_bin" build -buildvcs=false -o "$_work/abcd" ./cmd/abcd) >/dev/null 2>&1 && [ -x "$_work/abcd" ]; then + sources_relay "$_work/abcd" "the abcd built from this checkout" + else + echo "pre-commit: warning — could not build ./cmd/abcd from this checkout (run 'go build ./cmd/abcd' to see why), so the sources banlist block was not refreshed; the banlist is checked as it stands." >&2 + fi + rm -rf "$_work" + trap - EXIT INT TERM HUP +} + +if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then + echo "pre-commit: no sources corpus at ~/.abcd/sources — generated banlist block not refreshed (skipped)" >&2 +elif [ ! -f "$banlist" ]; then + echo "pre-commit: this working tree has no private store at $banlist, and the sources refresh never creates one; run 'abcd source sync-banlist' here to opt in (skipped)" >&2 +else + sources_first="" + IFS= read -r sources_first < "$banlist" || true + if declares_format "$sources_first"; then + sources_refresh + elif [ ! -e "$sources_noticed" ] && [ ! -L "$sources_noticed" ]; then + echo "pre-commit: $banlist predates the keyed format, so the sources banlist block is not refreshed into it; migrate it once with 'abcd banlist migrate' (said once per working tree)" >&2 + # Created with O_EXCL (noclobber), in a directory the refusals above proved is + # not a symlink, so the marker can never truncate a file it points at. + (set -C; : >"$sources_noticed") 2>/dev/null || true + fi +fi +# --- end itd-76 refresh ------------------------------------------------------- + # Which stores does this checkout actually have? Two at most: this working tree's # own, and — in a linked worktree — the primary checkout's, inherited. local_present=0 diff --git a/commands/consult.md b/commands/consult.md index 2f74fb0ec..0e8dde3a8 100644 --- a/commands/consult.md +++ b/commands/consult.md @@ -74,12 +74,15 @@ decision, and the line number, so they can decide about citing. ## Guard wiring - The repository's committed pre-commit guard runs - `abcd source sync-banlist --refresh` on every commit, which regenerates a - fenced block of confidential titles and aliases in the untracked - `.abcd/.work.local/private-names.txt`; leakage is then blocked mechanically, - not just by this command's rule. After adding or declassifying a source, run - `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist` so the block is current - before the next commit. + `abcd source sync-banlist --refresh` on every commit (in a managed + repository, once the clone opts in with + `git config --local abcd.sourcesBinary /absolute/path/to/abcd`), which + regenerates a fenced block of confidential titles and aliases in the untracked + `.abcd/.work.local/private-names.txt` when that store already exists; + leakage is then blocked mechanically, not just by this command's rule. The + first sync in a repository, and the one after adding or declassifying a + source, is `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist`, run by hand so + the block is current before the next commit. - Before any document that drew on confidential material is committed, posted, or otherwise shared, run `"${CLAUDE_PLUGIN_ROOT}/abcd" source cite-check <file>` (exit 1 = a confidential identifier is present; its report names only the diff --git a/commands/ingest.md b/commands/ingest.md index 32f6c62b9..160e2c9c7 100644 --- a/commands/ingest.md +++ b/commands/ingest.md @@ -87,8 +87,8 @@ the verb does not make). If the source is webloc/link-only there is no body ## 5. Close out - Confidential ingest → run `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist` - in every guarded repo the session touches (the guard also refreshes it on - the next commit). + in every guarded repo the session touches (a guard that refreshes the block + does so on the next commit, but only a store that already exists). - If the user wants a summary or review kept: write it to the source's own folder (`summary.md`, notes as siblings) — derived artifacts inherit the source's class by location, never anywhere else. diff --git a/commands/source.md b/commands/source.md index ecb350996..0436f9716 100644 --- a/commands/source.md +++ b/commands/source.md @@ -82,10 +82,13 @@ checkout's root commit unless `--repo` names it. Projects every confidential source's title and aliases (authors only under `ban_authors`) into this repository's untracked private banlist, as a fenced block it owns. Run by hand, it creates the store when there is something to ban. The -committed pre-commit guard runs `sync-banlist --refresh` on every commit, which -updates a store that already exists and never creates one, so the first sync in a +committed pre-commit guard runs `sync-banlist --refresh` on every commit (in a +managed repository, only once the clone opts in with +`git config --local abcd.sourcesBinary /absolute/path/to/abcd`), which updates a +store that already exists and never creates one, so the first sync in a repository is always the user's own, and running it by hand also matters after an -add or a declassification, before the next commit. A refusal naming keys means folders and entries disagree: repair +add or a declassification, before the next commit. A store in the legacy format is +not refreshed: the guard and the verb both name `abcd banlist migrate`. A refusal naming keys means folders and entries disagree: repair those entries (the block already written keeps banning meanwhile). ## Scan before sharing diff --git a/internal/core/ahoy/defaults/pre-commit b/internal/core/ahoy/defaults/pre-commit index 4f03631c5..bea83bf3a 100644 --- a/internal/core/ahoy/defaults/pre-commit +++ b/internal/core/ahoy/defaults/pre-commit @@ -371,40 +371,6 @@ refuse_store_copy() { fi } -# --- the sources corpus: refresh its generated block ----------------------------- -# The user-level sources corpus (`abcd source`) projects every confidential -# source's title and aliases (and, where an entry opts in, its authors) into a -# generated block of the private store below. The block is refreshed HERE, before -# the store is read, so the check is never more than this commit stale. -# -# Never a failure, never silent. No corpus is one line and the commit proceeds — -# CI, a fresh clone, and every machine that never made a corpus take that branch. -# A corpus with no abcd binary to refresh it, or a refresh that fails, is one line -# and the check runs against the store as it stands (the block already written -# keeps banning). The binary's own output is discarded: its refusals name keys -# only, and `abcd source sync-banlist` run by hand shows them. -# -# The binary is looked up on the pinned PATH (plus abcd.guardPath), then at the -# user-level install location ~/.local/bin, which is user-owned and not -# repository-scoped. The corpus location is the default one; relocating the -# user-level home is a separate design. -sources_corpus="${HOME:-}/.abcd/sources" -if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then - echo "pre-commit: no sources corpus at ~/.abcd/sources — generated banlist block not refreshed (skipped)" >&2 -else - abcd_bin=$(command -v abcd 2>/dev/null || true) - if [ -z "$abcd_bin" ] && [ -x "$HOME/.local/bin/abcd" ]; then - abcd_bin="$HOME/.local/bin/abcd" - fi - if [ -z "$abcd_bin" ]; then - echo "pre-commit: warning — a sources corpus exists but no abcd binary is on the guard's PATH or in ~/.local/bin;" >&2 - echo " the generated banlist block was not refreshed, so the banlist is checked as it stands." >&2 - elif ! "$abcd_bin" source sync-banlist --refresh >/dev/null 2>&1; then - echo "pre-commit: warning — the sources banlist refresh failed (run 'abcd source sync-banlist' to see why);" >&2 - echo " the banlist is checked as it stands." >&2 - fi -fi - # A path that exists but is not a REGULAR FILE is tampering, not non-adoption: a # symlink (which `git add -f` can commit, so a checkout materialises it) swaps or # empties the guard, and a directory or FIFO there would take the "absent" branch @@ -444,6 +410,105 @@ if [ -n "$primary_banlist" ]; then refuse_non_regular_store "$primary_banlist" "$primary_dir" "$primary_label" fi +# --- the sources corpus: refresh its generated block -------------------------- +# abcd's user-level sources corpus (`abcd source`) projects every confidential +# source's title and aliases (and, where an entry opts in, its authors) into a +# generated block of this working tree's private store. This guard can refresh the +# block before it reads the store, so the check is never more than one commit +# stale — ON OPT-IN, per clone: +# +# git config --local abcd.sourcesBinary /absolute/path/to/abcd +# +# That setting is the ONLY way this hook finds an abcd binary: an absolute path to +# an executable regular file, read through git's repo-local config — never a PATH +# search, and never an environment variable, which a repo-scoped direnv could set. +# How a scaffolded hook should find abcd, and whether it should by default, is a +# decision this template deliberately does not take; until it is taken, nothing +# runs unless the clone asks for it. +# +# Never a failure, never silent, and never a store this commit did not already +# have. Each branch below is ONE line and the commit proceeds: +# * no corpus — CI, a fresh clone, every machine that never made one; +# * no opt-in, or a setting that is not an absolute path to an executable file; +# * no private store in this working tree — the refresh updates a store, it never +# creates one, because creating it writes every confidential title into this +# repository's local tier, which is the person's act (`abcd source +# sync-banlist`, by hand), not a side effect of a commit; +# * a legacy store — named with its migration on the first commit that meets it +# and not again, because a line printed on every commit is a line the +# committer learns to skip (the store's own format line below says "legacy" on +# every commit regardless); +# * a binary that has no `source sync-banlist --refresh`. +# Otherwise the refresh runs and its own output is RELAYED, never discarded: its +# one-line count is how a refresh that wrote fewer patterns than the last one +# becomes visible, and its refusals name keys only. +sources_corpus="${HOME:-}/.abcd/sources" +sources_noticed="$banlist_dir/sources-legacy-noticed" + +# sources_relay runs one refresh binary and relays what it says, prefixed. A binary +# that has no `source sync-banlist --refresh` is ONE line naming the remedy, not a +# failure, and its own error text is not relayed. +sources_relay() { + _bin="$1" + _what="$2" + _rc=0 + _out=$(unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_PREFIX + "$_bin" source sync-banlist --refresh 2>&1) || _rc=$? + if [ "$_rc" -ne 0 ]; then + _help=$("$_bin" source sync-banlist --help 2>&1 || true) + case $_help in + *--refresh*) ;; + *) + echo "pre-commit: $_what has no 'source sync-banlist --refresh', so the sources banlist block was not refreshed; point the setting at an abcd that has it, or unset it (skipped)" >&2 + return 0 + ;; + esac + fi + while IFS= read -r _line || [ -n "$_line" ]; do + if [ -n "$_line" ]; then + printf 'pre-commit: %s\n' "$_line" >&2 + fi + done <<<"$_out" + if [ "$_rc" -ne 0 ]; then + echo "pre-commit: warning — the sources banlist refresh failed; the banlist is checked as it stands." >&2 + fi +} + +sources_bin=$(git config --local --get abcd.sourcesBinary 2>/dev/null || true) +if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then + echo "pre-commit: no sources corpus at ~/.abcd/sources — generated banlist block not refreshed (skipped)" >&2 +elif [ -z "$sources_bin" ]; then + echo "pre-commit: a sources corpus exists; this hook refreshes its banlist block only on opt-in: git config --local abcd.sourcesBinary /absolute/path/to/abcd (skipped)" >&2 +else + # The configured path is NOT printed: a directory name on it can be the very + # private name the store bans. + case "$sources_bin" in + /*) sources_bin_ok=1 ;; + *) sources_bin_ok=0 ;; + esac + if [ "$sources_bin_ok" -eq 1 ] && { [ ! -f "$sources_bin" ] || [ ! -x "$sources_bin" ]; }; then + sources_bin_ok=0 + fi + if [ "$sources_bin_ok" -eq 0 ]; then + echo "pre-commit: abcd.sourcesBinary is not an absolute path to an executable file, so the sources banlist block was not refreshed (skipped)" >&2 + elif [ ! -f "$banlist" ]; then + echo "pre-commit: this working tree has no private store at $banlist, and the sources refresh never creates one; run 'abcd source sync-banlist' here to opt in (skipped)" >&2 + else + sources_first="" + IFS= read -r sources_first < "$banlist" || true + if declares_format "$sources_first"; then + sources_relay "$sources_bin" "the abcd that abcd.sourcesBinary names" + elif [ ! -e "$sources_noticed" ] && [ ! -L "$sources_noticed" ]; then + echo "pre-commit: $banlist predates the keyed format, so the sources banlist block is not refreshed into it; migrate it once with 'abcd banlist migrate' (said once per working tree)" >&2 + # Created with O_EXCL (noclobber), in a directory the refusals above proved is + # not a symlink, so the marker can never truncate a file it points at. + (set -C; : >"$sources_noticed") 2>/dev/null || true + fi + fi +fi +# --- end sources refresh ------------------------------------------------------ + # Which stores does this checkout actually have? Two at most: this working tree's # own, and — in a linked worktree — the primary checkout's, inherited. local_present=0 diff --git a/internal/core/banlist/hook_sources_test.go b/internal/core/banlist/hook_sources_test.go index 2cc7cf95e..7c571f356 100644 --- a/internal/core/banlist/hook_sources_test.go +++ b/internal/core/banlist/hook_sources_test.go @@ -2,6 +2,7 @@ package banlist import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -10,7 +11,20 @@ import ( // The pre-commit guard refreshes the sources corpus's generated block before it // checks anything (itd-76 AC3, AC6). Both copies of the guard carry the step — the // one this repository runs and the template `abcd ahoy` scaffolds into a managed -// repository — so every case runs against both. +// repository — and they find the binary differently: +// +// - this repository's copy builds ./cmd/abcd from the checkout, the rule for every +// abcd invocation in a source checkout, and never runs an installed abcd +// (iss-2609252007414882); +// - the template runs only the binary `git config --local abcd.sourcesBinary` +// names, an absolute path, and nothing when it is unset: how a scaffolded hook +// finds a binary is a ruling owed to the product thinker, and opt-in decides +// none of it (iss-2609252007419997). +// +// Either way the binary's own line is relayed, never discarded +// (iss-2609252007422630); the refresh updates an existing keyed store only +// (iss-2609252007426016); and a legacy store is named once, with its migration +// (iss-2609252007433563). func guardCopies(t *testing.T) map[string]string { t.Helper() hook := locateHook(t) @@ -35,14 +49,14 @@ func newGuardRepo(t *testing.T, hookPath, body string) *hookRepo { return r } -// fakeAbcd puts an `abcd` on the guard's PATH (through the repo-local guardPath -// extension) that records its arguments and then runs body, and returns the file -// the arguments land in. -func fakeAbcd(t *testing.T, r *hookRepo, body string) string { +// fakeAbcdOnPath puts an `abcd` on the guard's PATH (through the repo-local +// guardPath extension) that records its arguments, and returns the file the +// arguments land in. Neither copy of the guard may ever run it. +func fakeAbcdOnPath(t *testing.T, r *hookRepo) string { t.Helper() bin := t.TempDir() args := filepath.Join(t.TempDir(), "args") - script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > '" + args + "'\n" + body + "\n" + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > '" + args + "'\n" + writeEntrySh + "\n" if err := os.WriteFile(filepath.Join(bin, "abcd"), []byte(script), 0o755); err != nil { t.Fatal(err) } @@ -61,15 +75,119 @@ func makeCorpusDir(t *testing.T) { } } +// A refresher is what one fake binary does, written once for each copy: as shell +// for the template's opted-in binary, and as Go for the source tree this +// repository's copy builds. +type refresher struct{ sh, gocode string } + +const countLine = "abcd source sync-banlist — 1 confidential source, 1 pattern in .abcd/.work.local/private-names.txt (rewrote the block)" + +const writeEntrySh = "printf '# abcd-banlist: keyed\\nsources/conf2026a/title widgetworks\\n' > .abcd/.work.local/private-names.txt; echo '" + countLine + "'" + +var ( + writesEntry = refresher{ + sh: writeEntrySh, + gocode: `os.MkdirAll(".abcd/.work.local", 0o700) + os.WriteFile(".abcd/.work.local/private-names.txt", []byte("# abcd-banlist: keyed\nsources/conf2026a/title widgetworks\n"), 0o600) + os.Stdout.WriteString("` + countLine + `\n")`, + } + // failsNamingAKey is a binary that HAS the verb (its help names --refresh, as + // the real one's does) and refuses the refresh. + failsNamingAKey = refresher{ + sh: `case "$*" in *--help*) echo ' --refresh the refresh mode'; exit 0 ;; esac +echo 'abcd source sync-banlist: the corpus disagrees on conf2026a (nothing written)' >&2; exit 2`, + gocode: `if strings.Contains(strings.Join(os.Args, " "), "--help") { + os.Stdout.WriteString(" --refresh the guard's mode\n") + return + } + os.Stderr.WriteString("abcd source sync-banlist: the corpus disagrees on conf2026a (nothing written)\n") + os.Exit(2)`, + } +) + +// install makes body the refresh binary the given copy of the guard runs, and +// returns the file its arguments land in. +func (f refresher) install(t *testing.T, r *hookRepo, copyName string) string { + t.Helper() + args := filepath.Join(t.TempDir(), "args") + if copyName == "template" { + bin := filepath.Join(t.TempDir(), "abcd") + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > '" + args + "'\n" + f.sh + "\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + r.git("config", "--local", "abcd.sourcesBinary", bin) + return args + } + fakeSourceTree(t, r, `package main + +import ( + "os" + "strings" +) + +func main() { + os.WriteFile(`+"`"+args+"`"+`, []byte(strings.Join(os.Args[1:], " ")+"\n"), 0o600) + `+f.gocode+` +} +`) + return args +} + +// fakeSourceTree makes the throwaway repository an abcd source checkout — a go.mod +// and a cmd/abcd holding main — so this repository's copy of the guard, installed +// under .git/hooks, finds it at the working tree root and builds it. The tree is +// never staged. The Go caches are the real ones (read before the test HOME moved), +// so the build is not a cold one. +func fakeSourceTree(t *testing.T, r *hookRepo, main string) { + t.Helper() + r.write("go.mod", "module example.com/fakeabcd\n\ngo 1.21\n") + r.write("cmd/abcd/main.go", main) + r.env = append(r.env, goCacheEnv(t)...) +} + +var goCacheVals []string + +// goCacheEnv is the Go environment the hook's build runs under. +func goCacheEnv(t *testing.T) []string { + t.Helper() + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go unavailable: this repository's guard builds abcd") + } + if goCacheVals == nil { + t.Skip("the Go caches were not read before HOME moved") + } + return append([]string{"GOTOOLCHAIN=local", "GOFLAGS=-mod=mod"}, goCacheVals...) +} + +func init() { + // Read once, before any test moves HOME: a HOME-relative default cache would + // rebuild from cold per test. + out, err := exec.Command("go", "env", "GOCACHE", "GOMODCACHE", "GOPATH").Output() + if err != nil { + return + } + vals := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(vals) != 3 { + return + } + goCacheVals = []string{"GOCACHE=" + vals[0], "GOMODCACHE=" + vals[1], "GOPATH=" + vals[2]} +} + +// commitNote stages one file carrying text and attempts a commit. +func commitNote(r *hookRepo, name, text string) (bool, string) { + r.write(name, text) + r.git("add", name) + return r.commit() +} + // TestPreCommitHook_NoCorpusSaysSoAndProceeds is AC6 at the guard: no corpus is one // line naming the skip, and the commit proceeds. func TestPreCommitHook_NoCorpusSaysSoAndProceeds(t *testing.T) { for name, hook := range guardCopies(t) { t.Run(name, func(t *testing.T) { r := newGuardRepo(t, hook, "") - r.write("note.md", "hello\n") - r.git("add", "note.md") - blocked, out := r.commit() + blocked, out := commitNote(r, "note.md", "hello\n") if blocked { t.Fatalf("commit blocked with no corpus\n%s", out) } @@ -80,45 +198,73 @@ func TestPreCommitHook_NoCorpusSaysSoAndProceeds(t *testing.T) { } } -// TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking is AC3 at the guard: with -// a corpus present the guard runs `abcd source sync-banlist --refresh` BEFORE it -// reads the store, so an entry the refresh writes is enforced on this very commit. +// TestPreCommitHook_NeverRunsAnInstalledAbcd: an abcd on the guard's PATH is never +// the refresh binary, in either copy. This repository's copy with no source tree to +// build, and the template with no opt-in, each say so on one line and proceed. +func TestPreCommitHook_NeverRunsAnInstalledAbcd(t *testing.T) { + for name, hook := range guardCopies(t) { + t.Run(name, func(t *testing.T) { + makeCorpusDir(t) + r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key zzunrelated\n") + args := fakeAbcdOnPath(t, r) + blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") + if _, err := os.Stat(args); err == nil { + t.Fatalf("the guard ran the abcd on its PATH\n%s", out) + } + if blocked { + t.Fatalf("the commit was blocked\n%s", out) + } + want := map[string]string{"repo": "cmd/abcd", "template": "abcd.sourcesBinary"}[name] + if strings.Count(out, want) != 1 { + t.Fatalf("the skip is not one line naming %q\n%s", want, out) + } + }) + } +} + +// TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking is AC3 at the guard: the +// copy's own binary runs `source sync-banlist --refresh` BEFORE the store is read, +// so an entry the refresh writes is enforced on this very commit, and the binary's +// one-line count is relayed, so a refresh that wrote fewer patterns is visible. func TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking(t *testing.T) { for name, hook := range guardCopies(t) { t.Run(name, func(t *testing.T) { makeCorpusDir(t) r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key zzunrelated\n") - args := fakeAbcd(t, r, "printf '# abcd-banlist: keyed\\nsources/conf2026a/title widgetworks\\n' > .abcd/.work.local/private-names.txt") - r.write("note.md", "the widgetworks draft\n") - r.git("add", "note.md") - blocked, out := r.commit() + args := writesEntry.install(t, r, name) + blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") got, err := os.ReadFile(args) if err != nil { - t.Fatalf("the guard did not run abcd\n%s", out) + t.Fatalf("the guard did not run its refresh binary\n%s", out) } if strings.TrimSpace(string(got)) != "source sync-banlist --refresh" { - t.Fatalf("the guard ran abcd with %q", got) + t.Fatalf("the guard ran the binary with %q", got) } if !blocked || !strings.Contains(out, "sources/conf2026a/title") { t.Fatalf("the refreshed entry was not enforced on this commit\n%s", out) } + if !strings.Contains(out, "pre-commit: "+countLine) { + t.Fatalf("the binary's count line was not relayed\n%s", out) + } }) } } -// TestPreCommitHook_AFailedRefreshWarnsAndChecksTheStoreAsItStands: a refresh that -// fails is named, never fatal, and never silent; the existing store still guards. -func TestPreCommitHook_AFailedRefreshWarnsAndChecksTheStoreAsItStands(t *testing.T) { +// TestPreCommitHook_AFailedRefreshIsRelayedAndTheStoreStillGuards: a refresh that +// fails is relayed in the binary's own words (which name keys only), never fatal, +// and the existing store still guards. +func TestPreCommitHook_AFailedRefreshIsRelayedAndTheStoreStillGuards(t *testing.T) { for name, hook := range guardCopies(t) { t.Run(name, func(t *testing.T) { makeCorpusDir(t) r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key widgetworks\n") - fakeAbcd(t, r, "exit 2") - r.write("note.md", "the widgetworks draft\n") - r.git("add", "note.md") - blocked, out := r.commit() - if !strings.Contains(out, "refresh failed") { - t.Fatalf("a failed refresh was silent\n%s", out) + failsNamingAKey.install(t, r, name) + blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") + if !strings.Contains(out, "pre-commit: abcd source sync-banlist: the corpus disagrees on conf2026a") { + t.Fatalf("the refresh's refusal was not relayed\n%s", out) + } + if !strings.Contains(out, "checked as it stands") { + t.Fatalf("a failed refresh does not say the store is checked as it stands\n%s", out) } if !blocked || !strings.Contains(out, "hand-key") { t.Fatalf("the existing store stopped guarding after a failed refresh\n%s", out) @@ -126,3 +272,114 @@ func TestPreCommitHook_AFailedRefreshWarnsAndChecksTheStoreAsItStands(t *testing }) } } + +// TestPreCommitHook_RepoCopyWarnsOnceWhenTheBuildFails: this repository's copy +// builds ./cmd/abcd, and a tree that does not build is ONE warning line; the commit +// proceeds and the store is checked as it stands. +func TestPreCommitHook_RepoCopyWarnsOnceWhenTheBuildFails(t *testing.T) { + hook := guardCopies(t)["repo"] + makeCorpusDir(t) + r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key widgetworks\n") + fakeSourceTree(t, r, "package main\n\nthis does not compile\n") + blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") + if strings.Count(out, "could not build ./cmd/abcd") != 1 { + t.Fatalf("a failed build is not one warning line\n%s", out) + } + if strings.Contains(out, "does not compile") || strings.Contains(out, "syntax error") { + t.Fatalf("the build's own output was printed; the warning is one line\n%s", out) + } + if !blocked || !strings.Contains(out, "hand-key") { + t.Fatalf("the store stopped guarding after a failed build\n%s", out) + } +} + +// TestPreCommitHook_TemplateRefusesAnUnusableOptIn: abcd.sourcesBinary must be an +// absolute path to an executable regular file. Anything else is one line, nothing +// runs, and the commit proceeds — no PATH search stands in for it. +func TestPreCommitHook_TemplateRefusesAnUnusableOptIn(t *testing.T) { + hook := guardCopies(t)["template"] + for _, value := range []string{"abcd", "bin/abcd", "/nonexistent/abcd"} { + t.Run(value, func(t *testing.T) { + makeCorpusDir(t) + r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key zzunrelated\n") + args := fakeAbcdOnPath(t, r) + r.git("config", "--local", "abcd.sourcesBinary", value) + blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") + if _, err := os.Stat(args); err == nil { + t.Fatalf("an unusable opt-in fell back to the abcd on PATH\n%s", out) + } + if blocked || strings.Count(out, "abcd.sourcesBinary") != 1 || !strings.Contains(out, "absolute path") { + t.Fatalf("an unusable opt-in is not one line naming the setting\n%s", out) + } + }) + } +} + +// TestPreCommitHook_TemplateNamesABinaryWithoutTheVerbOnce: an opted-in binary that +// predates the source verb is ONE line naming the remedy — never a "refresh failed" +// warning, which on every commit would teach the committer to ignore it. +func TestPreCommitHook_TemplateNamesABinaryWithoutTheVerbOnce(t *testing.T) { + hook := guardCopies(t)["template"] + makeCorpusDir(t) + r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key zzunrelated\n") + old := refresher{sh: `case "$*" in *--help*) echo 'Usage: abcd [command]'; exit 0 ;; esac +echo 'abcd: unknown flag: --refresh' >&2; exit 1`} + old.install(t, r, "template") + blocked, out := commitNote(r, "note.md", "hello\n") + if blocked { + t.Fatalf("commit blocked\n%s", out) + } + if strings.Contains(out, "failed") || strings.Contains(out, "unknown flag") { + t.Fatalf("a binary without the verb reads as a failure\n%s", out) + } + if strings.Count(out, "has no 'source sync-banlist --refresh'") != 1 { + t.Fatalf("a binary without the verb is not one remedy line\n%s", out) + } +} + +// TestPreCommitHook_TheRefreshNeverCreatesTheStore: with a corpus and no private +// store in this working tree, neither copy runs its binary — creating the store is +// the by-hand sync's act — and each says so on one line. +func TestPreCommitHook_TheRefreshNeverCreatesTheStore(t *testing.T) { + for name, hook := range guardCopies(t) { + t.Run(name, func(t *testing.T) { + makeCorpusDir(t) + r := newGuardRepo(t, hook, "") + args := writesEntry.install(t, r, name) + blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") + if _, err := os.Stat(args); err == nil { + t.Fatalf("the guard ran its refresh binary with no store\n%s", out) + } + if _, err := os.Stat(filepath.Join(r.dir, filepath.FromSlash(PrivateRelPath))); !os.IsNotExist(err) { + t.Fatalf("the refresh created the private store (%v)\n%s", err, out) + } + if blocked || strings.Count(out, "never creates one") != 1 { + t.Fatalf("the skip is not one line\n%s", out) + } + }) + } +} + +// TestPreCommitHook_ALegacyStoreIsNamedOnce: a legacy store is not refreshed, the +// migration is named on the first commit that meets it, and not again — a notice on +// every commit is one the committer learns to skip. The binary never runs. +func TestPreCommitHook_ALegacyStoreIsNamedOnce(t *testing.T) { + for name, hook := range guardCopies(t) { + t.Run(name, func(t *testing.T) { + makeCorpusDir(t) + r := newGuardRepo(t, hook, "# legacy\nzzunrelated\n") + args := writesEntry.install(t, r, name) + blocked, out := commitNote(r, "one.md", "hello\n") + if blocked || strings.Count(out, "abcd banlist migrate") != 1 { + t.Fatalf("the first commit does not name the migration once\n%s", out) + } + blocked, out = commitNote(r, "two.md", "hello again\n") + if blocked || strings.Contains(out, "abcd banlist migrate") { + t.Fatalf("the second commit named the migration again\n%s", out) + } + if _, err := os.Stat(args); err == nil { + t.Fatalf("the guard ran its refresh binary over a legacy store\n%s", out) + } + }) + } +} diff --git a/internal/core/source/source_test.go b/internal/core/source/source_test.go index 521c3ada6..7018e7f21 100644 --- a/internal/core/source/source_test.go +++ b/internal/core/source/source_test.go @@ -561,3 +561,71 @@ func TestInitRefusesInsideAnotherRepository(t *testing.T) { t.Fatalf("init inside a repo: %v", err) } } + +// TestTheRepositoryGuardRefreshesWithItsOwnBuild is AC3 through this repository's +// real guard, end to end (iss-2609252007414882): a clone whose hooks path is this +// checkout's .githooks builds ./cmd/abcd from the checkout and runs ITS +// `source sync-banlist --refresh`, so a source added to the corpus after the last +// by-hand sync is banned on the very next commit, and the build's one-line count +// is relayed. No installed abcd is involved: the only abcd this test can reach is +// the one the hook builds. +func TestTheRepositoryGuardRefreshesWithItsOwnBuild(t *testing.T) { + for _, tool := range []string{"bash", "go"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s unavailable", tool) + } + } + // The Go caches are read BEFORE the HOME moves: the hook builds abcd, and a + // HOME-relative default cache would build it from cold. + goEnv, err := exec.Command("go", "env", "GOCACHE", "GOMODCACHE", "GOPATH").Output() + if err != nil { + t.Skipf("go env: %v", err) + } + vals := strings.Split(strings.TrimSpace(string(goEnv)), "\n") + if len(vals) != 3 { + t.Fatalf("go env returned %d values", len(vals)) + } + top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Skip("not in a checkout: the committed guard cannot be found") + } + hooks := filepath.Join(strings.TrimSpace(string(top)), ".githooks") + + corpus := newCorpus(t) + addConfidential(t, corpus, false) + repo := t.TempDir() + git(t, repo, "init", "-q") + git(t, repo, "config", "user.name", "Alice Example") + git(t, repo, "config", "user.email", "alice@example.com") + writeFile(t, repo, ".gitignore", ".abcd/.work.local/\n") + if _, err := SyncBanlist(corpus, repo, SyncOptions{}); err != nil { + t.Fatal(err) + } + git(t, repo, "config", "core.hooksPath", hooks) + + src := t.TempDir() + if _, err := Add(AddRequest{ + Corpus: corpus, Key: "conf2026b", Title: "Tidewater Staffing Memo", Type: "report", Class: ClassConfidential, + Original: writeFile(t, src, "memo.md", "# memo\nbody\n"), + }); err != nil { + t.Fatal(err) + } + + writeFile(t, repo, "leak.md", "per the tidewater staffing memo, we\n") + git(t, repo, "add", "leak.md", ".gitignore") + cmd := exec.Command("git", "-C", repo, "commit", "-q", "-m", "docs: a note") + cmd.Env = append(os.Environ(), "GOCACHE="+vals[0], "GOMODCACHE="+vals[1], "GOPATH="+vals[2], "GOFLAGS=-mod=mod") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("the guard let a source added after the last sync through:\n%s", out) + } + if !strings.Contains(string(out), "pre-commit: abcd source sync-banlist — 2 confidential sources") { + t.Errorf("the refresh's count line was not relayed:\n%s", out) + } + if !strings.Contains(string(out), "sources/conf2026b/title") { + t.Errorf("the refusal does not name the new source's key:\n%s", out) + } + if strings.Contains(strings.ToLower(string(out)), "tidewater") { + t.Errorf("the guard's output leaks the title:\n%s", out) + } +} From 6aab2234fc3a1d117d538e1fe314587e9cfcaec5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:29:28 +0100 Subject: [PATCH 013/107] =?UTF-8?q?chore:=20resolve=20iss-2609252007414882?= =?UTF-8?q?=20=E2=80=94=20the=20repository=20guard=20builds=20its=20own=20?= =?UTF-8?q?abcd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252007414882 Assisted-by: Claude:claude-opus-5-5 --- ...pository-s-committed-pre-commit-guard-refreshes-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md (64%) diff --git a/.abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md b/.abcd/work/issues/resolved/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md similarity index 64% rename from .abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md rename to .abcd/work/issues/resolved/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md index 8fafad1a7..e522faeba 100644 --- a/.abcd/work/issues/open/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md +++ b/.abcd/work/issues/resolved/iss-2609252007414882-this-repository-s-committed-pre-commit-guard-refreshes-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: ".githooks/pre-commit" +resolution: "this repository's .githooks/pre-commit builds ./cmd/abcd from the checkout for the sources refresh, as commit-msg does, never resolves an installed abcd, and warns on one line when the build fails" +impact: internal +resolved_by: + commit: "46af0661" --- This repository's committed pre-commit guard refreshes the sources corpus's generated banlist block with an INSTALLED abcd (the pinned PATH, then ~/.local/bin), which in this source checkout is the last release and stale by construction, against the dogfooding rule and against the commit-msg hook, which builds ./cmd/abcd from the checkout. The guard should build the checkout's own abcd the way commit-msg does, print one warning line and proceed when the build fails, and never resolve an installed binary. + +## Grounds + +- pursued: a commit in this checkout refreshes the block with the checkout's own verb; an installed abcd being run, or a build failure blocking or going unsaid, would show it wrong From b5eee4c420ab4fae1a03508e10785031df1701c8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:29:29 +0100 Subject: [PATCH 014/107] =?UTF-8?q?chore:=20resolve=20iss-2609252007419997?= =?UTF-8?q?=20=E2=80=94=20the=20scaffolded=20guard=20refreshes=20on=20opt-?= =?UTF-8?q?in=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252007419997 Assisted-by: Claude:claude-opus-5-5 --- ...caffolded-pre-commit-template-refreshes-the-sources.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md (62%) diff --git a/.abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md b/.abcd/work/issues/resolved/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md similarity index 62% rename from .abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md rename to .abcd/work/issues/resolved/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md index 88658e65e..d26804727 100644 --- a/.abcd/work/issues/open/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md +++ b/.abcd/work/issues/resolved/iss-2609252007419997-the-scaffolded-pre-commit-template-refreshes-the-sources.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/defaults/pre-commit" +resolution: "the scaffolded pre-commit refreshes the sources block only on repo-local opt-in (git config --local abcd.sourcesBinary, an absolute path to an executable file, no PATH search, no environment variable), one line otherwise; iss-2609250834251447's ruling stays open and can widen it" +impact: fix +resolved_by: + commit: "46af0661" --- The scaffolded pre-commit template refreshes the sources banlist block by default with a binary found on PATH or in ~/.local/bin, which answers by fiat the three questions iss-2609250834251447 holds as a ruling owed to the product thinker (how a scaffolded hook finds a binary, fail open or closed, default or opt-in). Until that ruling, the template should refresh only on repo-local opt-in, git config --local abcd.sourcesBinary naming an absolute path to a regular file, with no PATH search and never an environment variable, and otherwise print one line and proceed. + +## Grounds + +- pursued: a managed repository that did not opt in never runs an abcd from the sources refresh; any binary run without the setting, or a relative or PATH-found one, would show it wrong From 43de97ecc2730f5e4ac2089112497b2cc1f98d3f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:29:30 +0100 Subject: [PATCH 015/107] =?UTF-8?q?chore:=20resolve=20iss-2609252007422630?= =?UTF-8?q?=20=E2=80=94=20the=20guard=20relays=20the=20refresh's=20own=20l?= =?UTF-8?q?ine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252007422630 Assisted-by: Claude:claude-opus-5-5 --- ...-commit-guard-discards-the-sources-refresh-s-stdout.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md (64%) diff --git a/.abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md b/.abcd/work/issues/resolved/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md similarity index 64% rename from .abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md rename to .abcd/work/issues/resolved/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md index 37ad0ce27..bb0f7081a 100644 --- a/.abcd/work/issues/open/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md +++ b/.abcd/work/issues/resolved/iss-2609252007422630-the-pre-commit-guard-discards-the-sources-refresh-s-stdout.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: ".githooks/pre-commit" +resolution: "both copies of the guard relay the refresh binary's output (its one-line count, or its refusal naming keys) instead of discarding it, and name a binary without the verb in one remedy line" +impact: fix +resolved_by: + commit: "46af0661" --- The pre-commit guard discards the sources refresh's stdout and stderr (>/dev/null 2>&1), so an older abcd that has the source verb but projects fewer patterns rewrites the generated block weaker and the commit prints nothing about it: a silent downgrade. A binary with no source verb prints 'refresh failed' on every commit, noise that teaches the committer to ignore the one line that matters. The guard should print the binary's one-line count, and one clear remedy line when the binary lacks the verb. + +## Grounds + +- pursued: a refresh that wrote fewer patterns is visible on the commit that ran it; a commit whose refresh output is missing, or a verb-less binary reported as a failure, would show it wrong From 25e97a083d1b05a70411400327f9dc29ba04401c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:29:32 +0100 Subject: [PATCH 016/107] =?UTF-8?q?chore:=20resolve=20iss-2609252007426016?= =?UTF-8?q?=20=E2=80=94=20the=20refresh=20never=20creates=20the=20private?= =?UTF-8?q?=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252007426016 Assisted-by: Claude:claude-opus-5-5 --- ...re-commit-guard-s-sources-refresh-creates-abcd-work.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md (62%) diff --git a/.abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md b/.abcd/work/issues/resolved/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md similarity index 62% rename from .abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md rename to .abcd/work/issues/resolved/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md index 7b8c06bfb..d02b1831f 100644 --- a/.abcd/work/issues/open/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md +++ b/.abcd/work/issues/resolved/iss-2609252007426016-the-pre-commit-guard-s-sources-refresh-creates-abcd-work.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/banlist/generated.go" +resolution: "sync-banlist --refresh updates an existing keyed store only (banlist.RefreshGeneratedBlock, 3255cd70) and both guard copies skip the refresh with one line when this working tree has no store; creating it stays the by-hand sync" +impact: fix +resolved_by: + commit: "46af0661" --- The pre-commit guard's sources refresh CREATES .abcd/.work.local/private-names.txt in whatever repository the commit runs in when a corpus has confidential entries and no private store exists, writing every confidential title, alias and opted-in author as plaintext patterns into a directory that may be cloud-synced or bind-mounted. The refresh should update an existing keyed store only; creating one stays the by-hand abcd source sync-banlist. + +## Grounds + +- pursued: no commit ever creates .abcd/.work.local/private-names.txt; a store or local tier appearing after a commit in a repository that had none would show it wrong From a1aa44d962bef52f74f55eb728894f55a10d1645 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:29:33 +0100 Subject: [PATCH 017/107] =?UTF-8?q?chore:=20resolve=20iss-2609252007433563?= =?UTF-8?q?=20=E2=80=94=20a=20legacy=20store=20has=20a=20migration,=20name?= =?UTF-8?q?d=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252007433563 Assisted-by: Claude:claude-opus-5-5 --- ...cy-unkeyed-private-store-with-entries-is-refused-by.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md (59%) diff --git a/.abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md b/.abcd/work/issues/resolved/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md similarity index 59% rename from .abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md rename to .abcd/work/issues/resolved/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md index 17a4853b8..aa342be37 100644 --- a/.abcd/work/issues/open/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md +++ b/.abcd/work/issues/resolved/iss-2609252007433563-a-legacy-unkeyed-private-store-with-entries-is-refused-by.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/banlist/generated.go" +resolution: "abcd banlist migrate keys a legacy store in place under the guard's own entry-<line> keys (495273a4); every legacy refusal names it, and the guard names it once per working tree instead of warning on every commit" +impact: additive +resolved_by: + commit: "46af0661" --- A legacy (unkeyed) private store with entries is refused by abcd source sync-banlist with no migration path: the refusal asks the user to hand-key every line, and the pre-commit guard repeats a warning about it on every commit, for ever. The verb should offer a migration that keeps every line matching what it matched, and the guard should name it once, not warn on every commit. + +## Grounds + +- pursued: a legacy store is migrated in one command and matches exactly what it matched; a key or pattern changing across the migration, or the guard repeating the notice, would show it wrong From aac2da4231850b9dd9fa24796a00ce450648fadc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:49:29 +0100 Subject: [PATCH 018/107] fix(implement): build resumes its live run before judging the checks `abcd build <itd-N>` ran the pre-start checks before looking up the live run for the key, so starting again re-judged a tree the run itself had changed: once the run's own lane worktree moved the intent to shipped/, or the lane claimed it, the peers check refused the run as its own peer (exit 3), and the documented resume failed exactly when it was needed. Start now checks the key's shape, then looks up a live run for the key under the lock and returns it resumed, and runs the checks only when it creates a run. The lookup inside the create lock stays, so a start that raced past the first lookup is still resumed rather than duplicated. A resumed start carries no check rows: it ran none. The test plays the reviewer's reproduction: a worktree under the machine-scoped store in a temp HOME that ships the intent, and the lane's claim in the shared run store; both refused before the fix. Refs: iss-2609252047459767 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/31-build.md | 8 +- ...-run-again-while-its-run-is-in-progress.md | 13 +++ commands/build.md | 9 +- docs/reference/cli/commands.md | 7 +- internal/core/implement/loop/loop.go | 84 +++++++++++++++---- internal/core/implement/loop/loop_test.go | 69 +++++++++++++++ internal/surface/cli/build.go | 7 +- 7 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md diff --git a/.abcd/development/brief/04-surfaces/31-build.md b/.abcd/development/brief/04-surfaces/31-build.md index ac7b10ccb..12d5cc0c0 100644 --- a/.abcd/development/brief/04-surfaces/31-build.md +++ b/.abcd/development/brief/04-surfaces/31-build.md @@ -28,7 +28,7 @@ later pieces of the spec; until each lands, the loop refuses at it by name. ## The checks -Nothing starts until every check passes, and each is a read (criteria 1 and 2): +No run is created until every check passes, and each is a read (criteria 1 and 2): - **key** — the record is an intent. The issue key (decision 10) is refused by name until the piece that admits it lands. @@ -85,7 +85,11 @@ receipt and pull request. Starting creates one lane, for the first unlanded spec step, and records the rest as pending. Starting again while that run is in progress creates nothing -and names the run. +and names the run. The live run for the key is looked up first, under the lock, +and the checks run only when a run is created: they judged the record at the +start, and the run's own lanes then change what they read (a lane's worktree +moves the intent to `shipped/`, a lane claims it), so judging again would refuse +the run as its own peer. Only the key's shape is checked before the lookup. ## The step interface diff --git a/.abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md b/.abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md new file mode 100644 index 000000000..754d53362 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609252047459767" +slug: "abcd-build-itd-n-run-again-while-its-run-is-in-progress" +severity: "major" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +abcd build <itd-N> run again while its run is in progress re-runs the pre-start checks before looking up the live run (loop.Start, internal/core/implement/loop/loop.go), so once the run's own lane changes the tree the resume the verb documents is refused: a worktree or branch that moves the intent to shipped/ (the Delivers-trailer definition of done), or the lane's own claim in the shared run store, makes the peers check exit 3 'held by a peer', and after a land the ready row refuses. build.go's Long text, 31-build.md and commands/build.md all promise that starting again resumes. Remedy: under the lock, look up a live run for the key first and return it resumed; run the checks only when creating. diff --git a/commands/build.md b/commands/build.md index cd6cfb83b..233b737f8 100644 --- a/commands/build.md +++ b/commands/build.md @@ -18,7 +18,8 @@ last one stopped. "${CLAUDE_PLUGIN_ROOT}/abcd" build <itd-N> --json ``` -The checks run first, and every one must pass: +For an intent with no run in progress, the checks run first, and every one must +pass: - `key` — the argument is an intent id. An issue id is refused: the issue key is not built yet. @@ -43,8 +44,10 @@ When the checks pass, the payload names the `run_id`, the `state` file (`.abcd/.work.local/run/<run-id>/state.json`), the first `lane` (the spec's first unlanded step), the `pending` spec steps, and `next`, the move to make. The local tier is never created: in a repository abcd does not manage the verb -refuses. Starting again while the run is in progress creates nothing and -reports `resumed: true` with the same run. +refuses. Starting again while the run is in progress creates nothing, runs no +check, and reports `resumed: true` with the same run and an empty `checks`: the +run's own lanes move and claim the intent, so judging it again would refuse the +run as its own peer. ## Drive it diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 0fefd0368..b9b15364e 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -159,7 +159,8 @@ Start a run that takes one READY intent to delivered, refusing while a decision **Usage:** `abcd build <itd-N>` -Start the implement loop for one intent. The checks run first, and every one must pass: +Start the implement loop for one intent, or resume the run already in progress for it. +A new run's checks run first, and every one must pass: the intent is READY (planned, criteria written, its spec linked and written), asks no open question, has no unanswered claim section, is not held, its spec leaves a step to build, and no peer holds it (no sibling worktree or local branch holds it in another @@ -170,8 +171,8 @@ When the checks pass, the run is created in this checkout's local tier, `.abcd/.work.local/run/<run-id>/state.json`: one lane for the spec's first unlanded step, the other unlanded steps pending, and the run record's first line. The tier itself is never created: only a repository abcd manages has one. Starting again while the run is -in progress creates nothing and names the run, so a killed process resumes where it -stopped. +in progress creates nothing and names the run without judging the checks again (the +run's own lanes change what they read), so a killed process resumes where it stopped. The run then moves one step per `abcd implement step`, driven by the host session. diff --git a/internal/core/implement/loop/loop.go b/internal/core/implement/loop/loop.go index 4e3ed19ce..2244f63f9 100644 --- a/internal/core/implement/loop/loop.go +++ b/internal/core/implement/loop/loop.go @@ -123,8 +123,10 @@ type StartResult struct { Resumed bool `json:"resumed"` Lane Lane `json:"lane"` Pending []PendingStep `json:"pending"` - Checks []CheckRow `json:"checks"` - Next string `json:"next"` + // Checks are the pre-start checks' rows when the call created the run; a + // resumed start runs none, and carries none. + Checks []CheckRow `json:"checks"` + Next string `json:"next"` } // StepResult is what Advance and Receipt return. @@ -142,15 +144,29 @@ type StepResult struct { Next string `json:"next"` } -// Start runs the checks for key and, when every one passes, creates the run: -// the state file with one lane at the sequence's first step, the spec's other -// unlanded steps pending, and the record's first line. A refused check writes -// nothing. A live run for the same key in this checkout is resumed — named, not -// duplicated — so starting again after a kill loses nothing and repeats nothing. +// Start resumes the live run for key, or runs the checks and, when every one +// passes, creates the run: the state file with one lane at the sequence's first +// step, the spec's other unlanded steps pending, and the record's first line. A +// refused check writes nothing. +// +// A live run for the same key in this checkout is looked up first, under the +// lock, and resumed — named, not duplicated, and not re-judged. The checks +// judged the record when the run was created; since then the run's own lanes +// change the tree they read (a lane's worktree moves the intent to shipped/, a +// lane claims it), so judging again would refuse the run as its own peer. So +// starting again after a kill loses nothing and repeats nothing, and the checks +// run only when a run is created. Only the key's shape is checked before the +// lookup, so a path is never built from a key that is not an intent id. func Start(repoRoot, key string, o Options) (StartResult, error) { if err := tierPresent(repoRoot); err != nil { return StartResult{}, err } + if row, ok := keyCheck(key); !ok { + return StartResult{}, CheckResult{Key: key, Checks: []CheckRow{row}}.refusal() + } + if res, ok, err := resumeLive(repoRoot, key); err != nil || ok { + return res, err + } chk, err := Check(repoRoot, key) if err != nil { return StartResult{}, err @@ -167,11 +183,11 @@ func Start(repoRoot, key string, o Options) (StartResult, error) { if err != nil { return err } - for _, st := range runs { - if recordid.SameID(st.Key, chk.Key) && !st.Complete() { - res = startResult(st, chk, true) - return nil - } + // A start that raced this one past the lookup above created the run + // while the checks ran: it is resumed, not duplicated. + if st, ok := liveRun(runs, chk.Key); ok { + res = startResult(st, nil, true) + return nil } id, err := freeRunID(root, o.Minter) if err != nil { @@ -200,15 +216,53 @@ func Start(repoRoot, key string, o Options) (StartResult, error) { if err := writeState(root, st); err != nil { return err } - res = startResult(st, chk, false) + res = startResult(st, chk.Checks, false) return nil }) return res, err } -func startResult(st State, chk CheckResult, resumed bool) StartResult { +// resumeLive returns the live run for key, under the lock, when this checkout +// has one. A checkout with no run directory has none, and the lookup creates +// nothing; a run directory that is not a real directory is left to the create +// path, which refuses it. +func resumeLive(repoRoot, key string) (StartResult, bool, error) { + if !fsutil.IsRealDir(filepath.Join(repoRoot, filepath.FromSlash(RunRelDir))) { + return StartResult{}, false, nil + } + var res StartResult + found := false + err := withLock(repoRoot, func(root *os.Root) error { + runs, err := readRuns(root) + if err != nil { + return err + } + if st, ok := liveRun(runs, key); ok { + res, found = startResult(st, nil, true), true + } + return nil + }) + return res, found, err +} + +// liveRun returns the run for key that is not complete. +func liveRun(runs []State, key string) (State, bool) { + for _, st := range runs { + if recordid.SameID(st.Key, key) && !st.Complete() { + return st, true + } + } + return State{}, false +} + +// startResult reports a run as Start returns it. checks are the rows the call +// ran: a resumed start runs none. +func startResult(st State, checks []CheckRow, resumed bool) StartResult { + if checks == nil { + checks = []CheckRow{} + } res := StartResult{RunID: st.RunID, State: StateRelPath(st.RunID), Resumed: resumed, - Pending: st.Pending, Checks: chk.Checks} + Pending: st.Pending, Checks: checks} if i := st.current(); i >= 0 { res.Lane = st.Lanes[i] res.Next = nextMove(st, st.Lanes[i]) diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go index 278456fa3..217929970 100644 --- a/internal/core/implement/loop/loop_test.go +++ b/internal/core/implement/loop/loop_test.go @@ -178,6 +178,75 @@ func TestStartRefusesAPeerHoldingTheRecord(t *testing.T) { }) } +// TestStartAgainResumesTheRunItsOwnLaneChanged is criterion 7's resume once +// the run has changed the tree it was judged on: its lane's worktree (in the +// machine-scoped store, piece 6's shape) delivers the intent to shipped/, or +// its lane holds a claim on it. The checks judged the record at the start; a +// live run for the key is found first and resumed, not re-judged into a +// refusal that names the run's own lane as a peer. +func TestStartAgainResumesTheRunItsOwnLaneChanged(t *testing.T) { + t.Run("its lane worktree ships the intent", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + first, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + wt := filepath.Join(os.Getenv("HOME"), ".abcd", "worktrees", "0123abcd", "lane-1") + if err := os.MkdirAll(filepath.Dir(wt), 0o755); err != nil { + t.Fatal(err) + } + repo.Git("worktree", "add", "-q", "-b", "build/lane-1", wt) + if err := os.MkdirAll(filepath.Join(wt, ".abcd", "development", "intents", "shipped"), 0o755); err != nil { + t.Fatal(err) + } + repo.Git("-C", wt, "mv", plannedRel, ".abcd/development/intents/shipped/itd-10-alpha.md") + repo.Git("-C", wt, "commit", "-q", "-m", "deliver alpha") + + again, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatalf("starting again while the run is in progress resumes it, whatever its lane did: %v", err) + } + if !again.Resumed || again.RunID != first.RunID { + t.Fatalf("want run %s resumed, got %+v", first.RunID, again) + } + }) + t.Run("its lane claims the intent", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + first, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + sha := repo.Git("rev-list", "--max-parents=0", "HEAD") + run, err := implement.Open(strings.TrimSpace(sha)) + if err != nil { + t.Fatal(err) + } + if _, err := run.Join("lane-session", implement.RoleFirst, "", "", 0); err != nil { + t.Fatal(err) + } + if _, err := run.Claim(implement.ClaimRequest{Session: "lane-session", Record: "itd-10", Lane: "lane-1"}); err != nil { + t.Fatal(err) + } + again, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatalf("the run's own claim must not refuse its resume: %v", err) + } + if !again.Resumed || again.RunID != first.RunID { + t.Fatalf("want run %s resumed, got %+v", first.RunID, again) + } + }) + t.Run("a key that is not an intent is refused before any lookup", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + if _, err := Start(repo.Root(), "itd-10", Options{}); err != nil { + t.Fatal(err) + } + _, err := Start(repo.Root(), "../itd-10", Options{}) + if r := mustRefusal(t, err); r.Check != CheckKey { + t.Fatalf("want the key check named: %+v", r) + } + }) +} + // TestStartRefusesWithoutTheLocalTier: the tier is never created, so a // repository abcd does not manage has no run. func TestStartRefusesWithoutTheLocalTier(t *testing.T) { diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index 1a263bd27..a4fd92116 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -79,7 +79,8 @@ func newBuildCommand(asJSON *bool) *cobra.Command { return &cobra.Command{ Use: "build <itd-N>", Short: "Start a run that takes one READY intent to delivered, refusing while a decision is open", - Long: "Start the implement loop for one intent. The checks run first, and every one must pass:\n" + + Long: "Start the implement loop for one intent, or resume the run already in progress for it.\n" + + "A new run's checks run first, and every one must pass:\n" + "the intent is READY (planned, criteria written, its spec linked and written), asks no\n" + "open question, has no unanswered claim section, is not held, its spec leaves a step to\n" + "build, and no peer holds it (no sibling worktree or local branch holds it in another\n" + @@ -89,8 +90,8 @@ func newBuildCommand(asJSON *bool) *cobra.Command { "`.abcd/.work.local/run/<run-id>/state.json`: one lane for the spec's first unlanded step,\n" + "the other unlanded steps pending, and the run record's first line. The tier itself is\n" + "never created: only a repository abcd manages has one. Starting again while the run is\n" + - "in progress creates nothing and names the run, so a killed process resumes where it\n" + - "stopped.\n\n" + + "in progress creates nothing and names the run without judging the checks again (the\n" + + "run's own lanes change what they read), so a killed process resumes where it stopped.\n\n" + "The run then moves one step per `abcd implement step`, driven by the host session.\n\n" + "Exit 2 on a refusal, exit 3 when a peer holds the intent or the run state is locked\n" + "(back off and take other work).", From b3076c937d9e48132e85df3e3168ecc014cea835 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:49:36 +0100 Subject: [PATCH 019/107] =?UTF-8?q?chore:=20resolve=20iss-2609252047459767?= =?UTF-8?q?=20=E2=80=94=20build=20resumes=20before=20judging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252047459767 Assisted-by: Claude:claude-opus-5-5 --- ...-build-itd-n-run-again-while-its-run-is-in-progress.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md (67%) diff --git a/.abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md b/.abcd/work/issues/resolved/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md similarity index 67% rename from .abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md rename to .abcd/work/issues/resolved/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md index 754d53362..2e77683c8 100644 --- a/.abcd/work/issues/open/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md +++ b/.abcd/work/issues/resolved/iss-2609252047459767-abcd-build-itd-n-run-again-while-its-run-is-in-progress.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "Start looks up a live run for the key under the lock first and resumes it; the checks run only when a run is created. TestStartAgainResumesTheRunItsOwnLaneChanged plays a lane worktree that ships the intent and the lane's own claim." +impact: fix +resolved_by: + commit: "aac2da42" --- abcd build <itd-N> run again while its run is in progress re-runs the pre-start checks before looking up the live run (loop.Start, internal/core/implement/loop/loop.go), so once the run's own lane changes the tree the resume the verb documents is refused: a worktree or branch that moves the intent to shipped/ (the Delivers-trailer definition of done), or the lane's own claim in the shared run store, makes the peers check exit 3 'held by a peer', and after a land the ready row refuses. build.go's Long text, 31-build.md and commands/build.md all promise that starting again resumes. Remedy: under the lock, look up a live run for the key first and return it resumed; run the checks only when creating. + +## Grounds + +- pursued: we expect build run again during a run to resume whatever its lanes did to the tree; shown wrong if any lane-made change (worktree, branch, claim) still refuses the resume From 8911d0885111e79ebddb9233f379588d8da36e0e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:53:32 +0100 Subject: [PATCH 020/107] fix(implement): the peers check fails closed on a peer it cannot read The build's peers check refused an unreadable claim as a holding, but a peer the peer listing named and could not read was skipped silently by Locate, so a peer whose holding is unknown read as holding nothing: fail-closed on one side, fail-open on the other. The peer listing now marks a NotRead peer whose holding is unknown (git or the filesystem would not answer for it, its record folders cannot be listed, or its ledger holds one id in two folders) and Report.Unjudged names them; the check counts each as a holding, naming it and why. A peer of another repository and one holding no records at the committed layout hold nothing of this checkout's and do not count, so an old branch does not block every build. The other half of the blind spot, a lane that has neither moved nor claimed the intent, is captured open: closing it needs Start to write a claim, which needs a session identity the host driver lacks. Refs: iss-2609252049491342 Refs: iss-2609252050506863 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/31-build.md | 9 ++++- ...heck-internal-core-implement-loop-check.md | 13 +++++++ ...heck-cannot-see-a-lane-that-has-neither.md | 13 +++++++ commands/build.md | 4 +- docs/reference/cli/commands.md | 3 +- internal/core/implement/loop/check.go | 28 ++++++++----- internal/core/implement/loop/loop_test.go | 39 +++++++++++++++++++ internal/core/peers/peers.go | 4 ++ internal/core/peers/peers_test.go | 28 +++++++++++++ internal/core/peers/read.go | 22 +++++++++++ internal/surface/cli/build.go | 3 +- 11 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md create mode 100644 .abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md diff --git a/.abcd/development/brief/04-surfaces/31-build.md b/.abcd/development/brief/04-surfaces/31-build.md index 12d5cc0c0..6cf6f2724 100644 --- a/.abcd/development/brief/04-surfaces/31-build.md +++ b/.abcd/development/brief/04-surfaces/31-build.md @@ -52,7 +52,14 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 holds it in a bucket other than this checkout's (a lane that shipped or re-drafted it), read through the peer listing, and no session holds a live claim on it in the shared run state. A copy in the same bucket is not a - holding: every branch cut from the default branch carries one. + holding: every branch cut from the default branch carries one. The check + fails closed on what it cannot see into, on both sides: a peer the listing + names and cannot read (git or the filesystem will not answer for it, or its + ledger holds one id in two folders) and an unreadable claim file each count + as a holding, naming why. A peer of another repository, or one holding no + records at the committed layout, holds nothing of this checkout's and does + not count. A lane that has neither moved nor claimed the intent is invisible + to both sources (iss-2609252050506863). A refusal names the check, the reason and the remedy, carries every check's row, and writes nothing. A peer's holding is contention rather than a fault in the diff --git a/.abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md b/.abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md new file mode 100644 index 000000000..5b8fdcffe --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609252049491342" +slug: "the-build-s-peers-check-internal-core-implement-loop-check" +severity: "minor" +category: "inconsistency" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The build's peers check (internal/core/implement/loop/check.go peersCheck) fails open on one side and closed on the other: a peer the peer listing names and does not read (Peer.NotRead: its directory or git refuses, its record folders cannot be listed, its ledger holds one id in two folders) is skipped silently by peers.Report.Locate, so a peer whose holding is unknown reads as holding nothing, while the claim half refuses an Unreadable claim as a holding. A record the check cannot see is not one it may start past; the unread peer should refuse as the unreadable claim does, naming it. diff --git a/.abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md b/.abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md new file mode 100644 index 000000000..9205d4c96 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609252050506863" +slug: "the-build-s-peers-check-cannot-see-a-lane-that-has-neither" +severity: "minor" +category: "architectural-insight" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The build's peers check cannot see a lane that has neither moved nor claimed the intent: the peer listing (peers.Report.Locate) sees a holding only when a sibling worktree or branch holds the intent in another bucket, and the claim half sees only a live claim in the shared run store, so a lane at steps 1 to 4 of any run (worktree made, brief written, implementer working, nothing committed that moves the intent) and a second clone of the repository are both invisible, and a second 'abcd build' of the same intent in another checkout starts a duplicate run. The other half of the same check, a peer named and not read being skipped silently while an unreadable claim refuses (fail-open on one side, fail-closed on the other), is iss-2609252049491342, fixed in the same lane. Closing this half needs Start to write a claim into the shared run store when it creates a run, which needs a session identity the host driver does not have yet. diff --git a/commands/build.md b/commands/build.md index 233b737f8..c02337aed 100644 --- a/commands/build.md +++ b/commands/build.md @@ -31,7 +31,9 @@ pass: - `hold` — the intent carries no `held:`. - `steps` — the spec's `## Steps` reads, and at least one step is not landed. - `peers` — no peer holds the intent: no sibling worktree or local branch holds - it in another bucket, and no session holds a live claim on it. + it in another bucket, and no session holds a live claim on it. A peer that + cannot be read (a worktree git will not answer for, a ledger holding one id + twice) and an unreadable claim count as holding it: what they hold is unknown. A refusal writes nothing. It exits 2, or 3 when a peer holds the intent (back off and take other work). Under `--json` the refusal comes as its own document diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b9b15364e..d62d68544 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -164,7 +164,8 @@ A new run's checks run first, and every one must pass: the intent is READY (planned, criteria written, its spec linked and written), asks no open question, has no unanswered claim section, is not held, its spec leaves a step to build, and no peer holds it (no sibling worktree or local branch holds it in another -bucket, and no session holds a live claim on it). A refusal names the check, the reason +bucket, and no session holds a live claim on it; a peer or claim that cannot be read +counts as holding it). A refusal names the check, the reason and the remedy, and writes nothing. When the checks pass, the run is created in this checkout's local tier, diff --git a/internal/core/implement/loop/check.go b/internal/core/implement/loop/check.go index fd6768e43..1c2cdc2ac 100644 --- a/internal/core/implement/loop/check.go +++ b/internal/core/implement/loop/check.go @@ -302,14 +302,12 @@ func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { if l.Folder == r.Bucket { continue } - who := "branch " + l.Branch - if l.Source == peers.SourceWorktree { - who = "the worktree at " + fsutil.RedactHome(l.Path) - if l.Branch != "" { - who += " (branch " + l.Branch + ")" - } - } - holders = append(holders, who+" holds it in "+l.Folder+"/") + holders = append(holders, peerName(l.Source, l.Branch, l.Path)+" holds it in "+l.Folder+"/") + } + // A peer the listing names and cannot read may hold the record; the check + // fails closed on it, as it does on an unreadable claim below. + for _, p := range rep.Unjudged() { + holders = append(holders, peerName(p.Source, p.Branch, p.Path)+" could not be read, so what it holds is unknown ("+fsutil.RedactHome(p.NotRead)+")") } if sha := gitutil.RootCommit(repoRoot); gitutil.IsFullSHA(sha) { run, err := implement.Peek(sha) @@ -337,6 +335,18 @@ func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { } row.contention = true row.Detail = r.IntentID + " is held by a peer: " + strings.Join(holders, "; ") - row.Remedy = "take other work, or coordinate with the peer; `abcd peers` and `abcd implement` show what each holds" + row.Remedy = "take other work, or coordinate with the peer; `abcd peers` and `abcd implement` show what each holds, and name why a peer is not read" return row, nil } + +// peerName names a peer for a refusal. +func peerName(src peers.Source, branch, path string) string { + if src != peers.SourceWorktree { + return "branch " + branch + } + who := "the worktree at " + fsutil.RedactHome(path) + if branch != "" { + who += " (branch " + branch + ")" + } + return who +} diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go index 217929970..d465a4b2f 100644 --- a/internal/core/implement/loop/loop_test.go +++ b/internal/core/implement/loop/loop_test.go @@ -247,6 +247,45 @@ func TestStartAgainResumesTheRunItsOwnLaneChanged(t *testing.T) { }) } +// TestStartRefusesAPeerItCannotRead: a peer the listing names and cannot read +// holds what nobody can say, so the peers check fails closed on it, as it does +// on an unreadable claim, naming the peer and why; a peer of the shape that +// holds nothing at the committed layout is not a holding. +func TestStartRefusesAPeerItCannotRead(t *testing.T) { + t.Run("a branch whose ledger holds one id twice", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + repo.Git("checkout", "-q", "-b", "lane-beta") + beta := "---\nid: itd-20\nslug: beta\n---\n# beta\n" + repo.Write(".abcd/development/intents/drafts/itd-20-beta.md", beta) + repo.Write(".abcd/development/intents/planned/itd-20-beta.md", beta) + repo.Commit("split beta") + repo.Git("checkout", "-q", "main") + + _, err := Start(repo.Root(), "itd-10", Options{}) + r := mustRefusal(t, err) + if r.Check != CheckPeers || !r.Contention { + t.Fatalf("want the peers check as contention: %+v", r) + } + if !strings.Contains(r.Reason, "lane-beta") || !strings.Contains(r.Reason, "could not be read") { + t.Fatalf("the refusal names the unread peer: %q", r.Reason) + } + runTierAbsent(t, repo.Root()) + }) + t.Run("a branch from before the record layout", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + repo.Git("checkout", "-q", "--orphan", "old-layout") + repo.Git("rm", "-rq", "--cached", ".") + repo.Write("README.md", "an old tree\n") + repo.Git("add", "README.md") + repo.Git("commit", "-q", "-m", "old") + repo.Git("checkout", "-q", "-f", "main") + + if _, err := Start(repo.Root(), "itd-10", Options{}); err != nil { + t.Fatalf("a peer holding no records at the layout holds nothing: %v", err) + } + }) +} + // TestStartRefusesWithoutTheLocalTier: the tier is never created, so a // repository abcd does not manage has no run. func TestStartRefusesWithoutTheLocalTier(t *testing.T) { diff --git a/internal/core/peers/peers.go b/internal/core/peers/peers.go index b269cef88..4c995d37e 100644 --- a/internal/core/peers/peers.go +++ b/internal/core/peers/peers.go @@ -96,6 +96,10 @@ type Peer struct { Rows []Row `json:"rows"` holdings holdings + // unjudged marks a NotRead peer whose holding is unknown — it could hold + // any record — rather than one that holds none of this repository's + // (another repository, or no records at the committed layout). + unjudged bool } // Skip reasons: a spent peer contributes no rows and is counted instead. diff --git a/internal/core/peers/peers_test.go b/internal/core/peers/peers_test.go index d529e95ab..af35afe39 100644 --- a/internal/core/peers/peers_test.go +++ b/internal/core/peers/peers_test.go @@ -352,6 +352,34 @@ func TestAForeignOrRefusedWorktreeIsNamedNotRead(t *testing.T) { } } +// Unjudged names the peers not read whose holding is unknown — git refused +// to answer for it, or its ledger is split — and not the peer of another +// repository, which holds nothing of this one's. +func TestUnjudgedNamesThePeersWhoseHoldingIsUnknown(t *testing.T) { + f := newFixture(t) + s := f.worktree("split", "feat/split") + f.write(s, ".abcd/work/issues/resolved/iss-1-the-first-finding.md", issue("iss-1", "The first finding")) + foreign := f.worktree("foreign", "feat/foreign") + if err := os.Remove(filepath.Join(foreign, ".git")); err != nil { + t.Fatal(err) + } + f.git(foreign, "init", "-q") + refused := f.worktree("refused", "feat/refused") + if err := os.WriteFile(filepath.Join(refused, ".git"), []byte("gitdir: "+filepath.Join(f.home, "nowhere")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for _, p := range f.read().Unjudged() { + if p.Source == peers.SourceWorktree { + got[p.Branch] = true + } + } + if !got["feat/split"] || !got["feat/refused"] || got["feat/foreign"] { + t.Fatalf("Unjudged worktrees = %v, want feat/split and feat/refused, not feat/foreign", got) + } +} + // Criterion 9: a checkout with no peers reports none. func TestACheckoutWithNoPeersReportsNone(t *testing.T) { f := newFixture(t) diff --git a/internal/core/peers/read.go b/internal/core/peers/read.go index bd2231895..943171232 100644 --- a/internal/core/peers/read.go +++ b/internal/core/peers/read.go @@ -158,6 +158,7 @@ func readWorktree(root, common string, wt worktree, merged map[string]bool, defa return p, &Skipped{Source: SourceWorktree, Branch: wt.branch, Path: wt.path, Reason: SkipGone}, true } p.NotRead = "its directory cannot be read: " + err.Error() + p.unjudged = true return p, nil, true } // The candidate's OWN answer, not this checkout's: a directory git lists can @@ -166,6 +167,7 @@ func readWorktree(root, common string, wt worktree, merged map[string]bool, defa theirs, err := commonDir(wt.path) if err != nil { p.NotRead = "git refused to answer for it: " + firstLine(err.Error()) + p.unjudged = true return p, nil, true } if realPath(theirs) != realPath(common) { @@ -188,9 +190,11 @@ func readWorktree(root, common string, wt worktree, merged map[string]bool, defa h, present, err := scanDisk(wt.path) if err != nil { p.NotRead = "its record folders cannot be read: " + err.Error() + p.unjudged = true return p, nil, false } p.NotRead = judgeHoldings(h, present) + p.unjudged = p.NotRead != "" && present if p.NotRead == "" { p.holdings = h } @@ -203,9 +207,11 @@ func readBranch(root, branch string) Peer { h, present, err := scanTree(root, "refs/heads/"+branch) if err != nil { p.NotRead = "git could not list its records: " + firstLine(err.Error()) + p.unjudged = true return p } p.NotRead = judgeHoldings(h, present) + p.unjudged = p.NotRead != "" && present if p.NotRead == "" { p.holdings = h } @@ -298,6 +304,22 @@ func (r Report) Locate(id string) []Location { return out } +// Unjudged returns the live peers that were named and not read for a reason +// that leaves what they hold unknown: git or the filesystem would not answer +// for them, or their ledger holds one id in two folders. Locate cannot see +// into these, so a caller that must not start past a peer's holding treats +// each as one. A peer of another repository, and one holding no records at +// the committed layout, hold nothing of this checkout's and are not returned. +func (r Report) Unjudged() []Peer { + var out []Peer + for _, p := range r.Peers { + if p.unjudged { + out = append(out, p) + } + } + return out +} + // HeldHere reports whether this checkout holds id in any folder of its family. func (r Report) HeldHere(id string) bool { return len(r.here[recordid.CanonCitedID(id)]) > 0 } diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index a4fd92116..ab4db0008 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -84,7 +84,8 @@ func newBuildCommand(asJSON *bool) *cobra.Command { "the intent is READY (planned, criteria written, its spec linked and written), asks no\n" + "open question, has no unanswered claim section, is not held, its spec leaves a step to\n" + "build, and no peer holds it (no sibling worktree or local branch holds it in another\n" + - "bucket, and no session holds a live claim on it). A refusal names the check, the reason\n" + + "bucket, and no session holds a live claim on it; a peer or claim that cannot be read\n" + + "counts as holding it). A refusal names the check, the reason\n" + "and the remedy, and writes nothing.\n\n" + "When the checks pass, the run is created in this checkout's local tier,\n" + "`.abcd/.work.local/run/<run-id>/state.json`: one lane for the spec's first unlanded step,\n" + From 4dcfda083e1450bc5ca15320ba14f7a50bcd6b13 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:53:34 +0100 Subject: [PATCH 021/107] =?UTF-8?q?chore:=20resolve=20iss-2609252049491342?= =?UTF-8?q?=20=E2=80=94=20an=20unread=20peer=20counts=20as=20a=20holding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252049491342 Assisted-by: Claude:claude-opus-5-5 --- ...ld-s-peers-check-internal-core-implement-loop-check.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md (60%) diff --git a/.abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md b/.abcd/work/issues/resolved/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md similarity index 60% rename from .abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md rename to .abcd/work/issues/resolved/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md index 5b8fdcffe..59ec5d42a 100644 --- a/.abcd/work/issues/open/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md +++ b/.abcd/work/issues/resolved/iss-2609252049491342-the-build-s-peers-check-internal-core-implement-loop-check.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "peersCheck counts every peer the listing names and cannot read, whose holding is unknown (peers.Report.Unjudged), as a holding, as it does an unreadable claim; a foreign-repository peer and a pre-layout peer do not count. TestStartRefusesAPeerItCannotRead and TestUnjudgedNamesThePeersWhoseHoldingIsUnknown." +impact: fix +resolved_by: + commit: "8911d088" --- The build's peers check (internal/core/implement/loop/check.go peersCheck) fails open on one side and closed on the other: a peer the peer listing names and does not read (Peer.NotRead: its directory or git refuses, its record folders cannot be listed, its ledger holds one id in two folders) is skipped silently by peers.Report.Locate, so a peer whose holding is unknown reads as holding nothing, while the claim half refuses an Unreadable claim as a holding. A record the check cannot see is not one it may start past; the unread peer should refuse as the unreadable claim does, naming it. + +## Grounds + +- pursued: we expect a build to refuse, naming it, while any peer it cannot read exists; shown wrong if an unread peer of this repository lets a start through, or an old-layout branch blocks one From b321386fe0a6a4b55fb0d467360b4341dad5774a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:55:34 +0100 Subject: [PATCH 022/107] fix(intent): the open-question reader knows the record's settled markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build's open_questions check counted every list item under `## Open Questions` as a question, so it refused intents the record had settled in its own words: itd-111's section opens "_All resolved or explicitly deferred at planning_", itd-93's "_All four resolved_", and items across the planned set are marked resolved or deferred. intent.OpenQuestions now recognises exactly the two markers those intents use: a section opening with an italic `_All resolved …_` line (at most one count word), and an item explicitly marked resolved or deferred, as a bold span opening with the word or as a `word:` label, continuation lines included. It stays fail-closed: an item led `**Open`, a pointer to another record, `**Out of scope**` and a question that only mentions deferral all still count. The cases are the records' own shapes. The rule is recorded in DECISIONS.md, with which of the 12 intents pass (itd-111, itd-93) and which still refuse. Refs: iss-2609252053576085 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/31-build.md | 14 ++-- .abcd/work/DECISIONS.md | 1 + ...estions-check-intent-openquestions-read.md | 13 ++++ commands/build.md | 5 +- internal/core/implement/loop/check.go | 5 +- internal/core/intent/questions.go | 71 +++++++++++++++++-- internal/core/intent/questions_test.go | 59 +++++++++++++++ 7 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md diff --git a/.abcd/development/brief/04-surfaces/31-build.md b/.abcd/development/brief/04-surfaces/31-build.md index 6cf6f2724..bd11854d1 100644 --- a/.abcd/development/brief/04-surfaces/31-build.md +++ b/.abcd/development/brief/04-surfaces/31-build.md @@ -35,10 +35,16 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 - **ready** — the implement-readiness gate the intent verb reports: planned, criteria written, the spec linked both ways and written past its stub. Its advisory rows stay advisory. -- **open questions** — no list item under the record's `## Open Questions`. The - reader is deliberately literal: an item is a question whatever it says, and a - record that has answered its questions says so in prose and keeps the answers - in `## Decisions`. +- **open questions** — no open question under the record's `## Open Questions`. + The reader fails closed and knows the record's two settled markers: a section + that opens with an italic `_All resolved …_` line (or `_All four resolved …_`) + is settled whole, and an item explicitly marked resolved or deferred — a bold + span opening with the word (`**Resolved — …**`, `**Deferred**`, `**explicitly + deferred**`, `**explicit deferral**`) or the word as a label (`resolved:`, + `Deferred:`) — is not a question. Every other list item is a question + whatever it says: one led `**Open`, one that only points to another record, + and one that merely mentions deferral all count (the 2026-09-25 entry in + `.abcd/work/DECISIONS.md`). - **claim sections** — the mechanism prompt is answered or the section absent, and the scope conditions are recorded. The readiness gate reports both as advisory; a run is where they bind, because an autonomous lane has nobody to diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 5ff693dc9..dec9b0fda 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2535,3 +2535,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-25 — Two rulings for the model-tier routing table, which the spec leaves open (lane implementer, autonomous run A, on spc-2609180535002478 part 1). First, a table is accepted when a routing file exists at the repository or the machine layer. Only then does the bundled proposal fill in an agent the table has no row for; with neither file, every agent resolves to `none`, the harness at `host-decides`, as AC 1 and the spec's criteria section say. A `--route` alone accepts nothing: it overrides the one agent it names for one run. Second, no agent contract under `agents/` declares a fan-out ceiling, and every agent in the roster is a single prompt that spawns no sub-agent, so each ceiling is 1. The proposal carries it (`oracle.Ceiling`), and a row's `fan_out` above it is reported and clamped. The roster test holds the proposal to `agents/`, so an agent that gains a ceiling field moves the number there. - 2026-09-25 — The load check's stray rule is "busy for its share" (ruling H1, the product thinker via the interview session, 07:57Z, on iss-2609231947544298). A long-running process outside abcd's lanes is a stray when it uses nearly all the CPU it could get on the machine as loaded: its lifetime CPU share is measured against its fair share, the online cores divided by the runnable demand, not against a fixed 0.9 of one core, so forty busy loops each at a fortieth of the machine all count. The share test applies to the caller's own processes and to other accounts' alike, and other accounts' strays stay counted only. It is not a second sample and not a summed-cores trigger. The build reads the runnable demand as the snapshot's one-minute load average and caps the fair share at one core, so on a machine loaded no higher than its cores the rule is the near-full core it was (`machineload.FairShare`, spc-2609232027132755). This closes the band between 1.125 and 4 times the cores in which the check said nothing (pinned by `TestStrayRuleSilentBand`, succeeded by `TestStrayRuleCoversTheOversubscribedBand`), and lets the load check's remainder spec close and itd-2609231434459890 ship. The check still warns and never refuses (the 2026-09-23 entry above on that intent). - 2026-09-25 — Autonomous run A defers every open capture routed to the product thinker out loud to v0.10.0, under the product thinker's directive of 2026-09-25 ("I want the ledger drained": a capture ends fixed, wontfix with its reason, closed as a duplicate, or deferred out loud where it needs a product-thinker ruling or a planning interview). The product thinker is away, so no one in the run can give those rulings. 190 records each carry `deferred_after: "v0.10.0"` and a `deferral_reason` that quotes the ruling owed verbatim. 184 of them renew a v0.9.0 grant that lapsed when v0.10.0 re-anchored, and 6 carried none. Every question is asked once in the run's rulings-owed list, grouped under the routing pass's eleven themes: A, planning interviews already ruled "plan next cycle" (27); B, confirmations owed on rulings already given (8); C, dependency and publish sign-offs (6); D, narrowing a shipped promise (4); E, principles and conventions to adopt (23); F, record schema and lint rules (34); G, security and trust design forks (16); H, autonomous runs, implement and multi-agent planning (22); I, site, docs voice and product story (17); J, future capabilities to plan or close (27); K, parked on a trigger, or a human act outside the tree (6). Within each theme the questions covering a major record come first, and the list is the agenda for the next interview. The same pass closes 9 duplicates and 44 captures on their recorded merits, so none of those is deferred (implementer of lane records1). +- 2026-09-25 — The build's open-question check reads the record's own settled convention and stays fail-closed (lane implementer, autonomous run A, fix round 1 of the loop lane, on iss-2609252053576085; the lane's first ruling, that every list item under `## Open Questions` is a question whatever it says, lived only in its report and refused intents the record had settled). `intent.OpenQuestions` recognises exactly two markers, learned from the 12 planned intents that carry list items there. A section whose first non-blank line is an italic `_All resolved …_`, with at most one count word (`_All four resolved …_`), is settled whole (itd-111, itd-93); `_All but one resolved_` is not. An item explicitly marked resolved or deferred is not a question: a bold span opening with the word (`**Resolved — …**`, `**Deferred**`, `**Deferred to a follow-up intent**`, `**explicitly deferred**`, `**explicit deferral**`) or the word as a label (`resolved:`, `RESOLVED:`, `Deferred:`), anywhere in the item including its continuation lines. Everything else stays a question: an item led `**Open` whatever it goes on to say (itd-60's "Open, and not gating scope"), an item that only points to another record (itd-76's "travel with itd-126"), `**Out of scope …**`, and a question that merely mentions deferral. With the rule, itd-111 and itd-93 pass the check; itd-7, itd-60, itd-65, itd-66, itd-76, itd-82, itd-117, itd-131, itd-2609170822093401 and itd-2609211116005482 still refuse, each on an item the record does not mark settled. Relabelling those items is the product thinker's call through the planning interview, not the loop's. diff --git a/.abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md b/.abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md new file mode 100644 index 000000000..9cb19a7c3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609252053576085" +slug: "the-build-s-open-questions-check-intent-openquestions-read" +severity: "minor" +category: "inconsistency" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +The build's open_questions check (intent.OpenQuestions, read by internal/core/implement/loop/check.go) counts every list item under '## Open Questions' as a question whatever it says, so it refuses records that follow the record's own settled convention: 12 of 50 planned intents carry list items there, and some are settled by their own words, itd-111's section opening '_All resolved or explicitly deferred at planning_' and itd-93's '_All four resolved_', itd-76's item marked '**explicitly deferred**', itd-60's items marked '**Resolved', itd-117's '**Deferred**' items. 'abcd build next' (itd-2609211116005482) would meet refusals nobody planned, and the ruling lives only in a lane report. Keep the check fail-closed, but read the markers the record actually uses. diff --git a/commands/build.md b/commands/build.md index c02337aed..bf06b492d 100644 --- a/commands/build.md +++ b/commands/build.md @@ -25,7 +25,10 @@ pass: is not built yet. - `ready` — the intent is READY: planned, its criteria written, its spec linked and written (the same gate `/abcd:intent` reports). -- `open_questions` — no list item under `## Open Questions`. +- `open_questions` — no open question under `## Open Questions`: every list + item there counts unless the section opens with an italic `_All resolved …_` + line or the item is explicitly marked resolved or deferred (`**Deferred**`, + `resolved:`, `**explicitly deferred**`). - `claim_sections` — the `## Mechanism` prompt is answered (or the section absent) and the scope conditions are recorded. - `hold` — the intent carries no `held:`. diff --git a/internal/core/implement/loop/check.go b/internal/core/implement/loop/check.go index 1c2cdc2ac..e73de84d8 100644 --- a/internal/core/implement/loop/check.go +++ b/internal/core/implement/loop/check.go @@ -82,7 +82,8 @@ const maxIntentBytes = 256 * 1024 // of the spec admits with drain's eligibility rule. // - ready: the implement-readiness gate (intent.Ready) — planned, criteria, // the spec linked and written. Its advisory rows stay advisory here. -// - open_questions: no list item under `## Open Questions`. +// - open_questions: no open question under `## Open Questions` +// (intent.OpenQuestions: every list item, less the settled markers). // - claim_sections: no unanswered claim section — the mechanism prompt // answered or the section absent, the scope conditions recorded. // - hold: no `held:` on the record (iss-2609200830076665). @@ -196,7 +197,7 @@ func openQuestionsRow(id, content string) CheckRow { return row } row.Detail = fmt.Sprintf("%s asks %d open question(s): %s", id, len(qs), strings.Join(qs, " | ")) - row.Remedy = "answer each in the record's `## Decisions` and mark `## Open Questions` settled, through the planning interview (/abcd:intent)" + row.Remedy = "answer each in the record's `## Decisions` and mark it settled (an item marked `resolved:` or `**Deferred**`, or a section opening `_All resolved …_`), through the planning interview (/abcd:intent)" return row } diff --git a/internal/core/intent/questions.go b/internal/core/intent/questions.go index fbd0ed13c..461260e30 100644 --- a/internal/core/intent/questions.go +++ b/internal/core/intent/questions.go @@ -17,6 +17,18 @@ var ( // numberedItemRe is a column-0 numbered list item, `1. text` or `1) text` — // the other spelling of a list a question is written in. numberedItemRe = regexp.MustCompile(`^[0-9]{1,4}[.)][ \t]+(\S.*)$`) + // allSettledRe is the italic line that opens a settled section and says + // every item below it is settled: `_All resolved …_`, or with one count + // word, `_All four resolved …_`. "All but one resolved" does not match. + allSettledRe = regexp.MustCompile(`(?i)^_all(\s+\w+)?\s+resolved\b`) + // openLeadRe is an item led by a bold "Open": a question, whatever else + // the item says. + openLeadRe = regexp.MustCompile(`(?i)^\*\*open\b`) + // settledMarkRe is an item's explicit disposition: a bold span that opens + // with it (`**Resolved — …**`, `**Deferred**`, `**explicitly deferred**`, + // `**explicit deferral**`), or the word as a label (`resolved:`, + // `RESOLVED:`, `Deferred:`). + settledMarkRe = regexp.MustCompile(`(?i)\*\*(resolved|deferred|explicitly deferred|explicit deferral)\b|\b(resolved|deferred)\s*:`) ) // OpenQuestions returns the questions an intent's `## Open Questions` section @@ -26,20 +38,67 @@ var ( // answered them — and prose, a blockquote and an indented continuation are not // questions. A record with no such section asks none. // -// It reads fail-closed rather than clever: an item is a question whatever it -// says, so a record that keeps its answered questions as list items under this -// heading reads as asking them. The remedy is the record's own convention — -// the answer moves to `## Decisions` and the section says it is settled. +// It reads fail-closed, recognising only the two settled markers the record +// uses (the 2026-09-25 DECISIONS entry): +// +// - a section whose first line is an italic `_All resolved …_` (or `_All +// four resolved …_`) is settled whole: every item below it is an answer +// kept for the reader; +// - an item explicitly marked resolved or deferred — a bold span opening +// with the word (`**Resolved — …**`, `**Deferred**`, `**explicitly +// deferred**`, `**explicit deferral**`) or the word as a label +// (`resolved:`, `Deferred:`) anywhere in the item, continuation lines +// included — is not a question. +// +// Everything else under the heading that is a list item is a question +// whatever it says: an item led `**Open`, an item that only points elsewhere, +// and a question that merely mentions deferral all count. The remedy is the +// record's own convention — the answer moves to `## Decisions` and the item or +// the section says it is settled. func OpenQuestions(content string) []string { var out []string + var item []string // the current item's first line, then its continuation lines + flush := func() { + if item == nil { + return + } + if !settledItem(strings.Join(item, " ")) { + out = append(out, item[0]) + } + item = nil + } + first := true for _, ln := range strings.Split(sectionBody(content, openQuestionsHeadingRe), "\n") { ln = strings.TrimRight(ln, "\r") + trimmed := strings.TrimSpace(ln) + if first && trimmed != "" { + first = false + if allSettledRe.MatchString(trimmed) { + return nil + } + } switch { case mdrecord.IsTopLevelBullet(ln): - out = append(out, strings.TrimSpace(mdrecord.TrimBulletPrefix(ln))) + flush() + item = []string{strings.TrimSpace(mdrecord.TrimBulletPrefix(ln))} case numberedItemRe.MatchString(ln): - out = append(out, strings.TrimSpace(numberedItemRe.FindStringSubmatch(ln)[1])) + flush() + item = []string{strings.TrimSpace(numberedItemRe.FindStringSubmatch(ln)[1])} + case trimmed == "": + case ln[0] == ' ' || ln[0] == '\t': + if item != nil { + item = append(item, trimmed) + } + default: + flush() } } + flush() return out } + +// settledItem reports whether an item's text, its continuation lines joined, +// carries an explicit resolved or deferred marker and is not led "Open". +func settledItem(text string) bool { + return !openLeadRe.MatchString(text) && settledMarkRe.MatchString(text) +} diff --git a/internal/core/intent/questions_test.go b/internal/core/intent/questions_test.go index 47f33a14f..c57de3805 100644 --- a/internal/core/intent/questions_test.go +++ b/internal/core/intent/questions_test.go @@ -35,3 +35,62 @@ func TestOpenQuestionsCountsListItemsAndNothingElse(t *testing.T) { }) } } + +// TestOpenQuestionsReadsTheSettledConvention pins the two markers the record +// uses for a settled section, taken from the planned intents that carry them +// (the 2026-09-25 DECISIONS entry): a section that opens with an italic +// "_All resolved …_" line (itd-111, itd-93), and an item explicitly marked +// resolved or deferred (itd-76, itd-60, itd-117, itd-111). It stays +// fail-closed: an item led "Open", an item that only points elsewhere, a +// question that merely mentions deferral, and an opener that does not open the +// section each still count. +func TestOpenQuestionsReadsTheSettledConvention(t *testing.T) { + t.Parallel() + const head = "## Open Questions\n\n" + cases := []struct { + name string + content string + want []string + }{ + {"all resolved or explicitly deferred (itd-111)", head + + "_All resolved or explicitly deferred at planning (2026-08-15):_\n\n" + + "- **Sampled re-surfacing** — graduated to its own\n capture, iss-230.\n" + + "- **Refusal breadth** — resolved: deliberately narrow.\n", nil}, + {"all four resolved (itd-93)", head + + "_All four resolved in the 2026-07-24 grill (see DECISIONS.md,\n2026-07-24 entries)._\n\n" + + "- **Which surface scaffolds it?** RESOLVED: a `launch` sub-verb.\n", nil}, + {"explicitly deferred, bold after the lead (itd-76)", head + + "- Ledger ownership once work spans machines: **explicitly deferred** (ruling, 2026-08-16).\n", nil}, + {"an explicit deferral split over two lines (itd-111)", head + + "- **Harness portability of the channel** — **explicit\n deferral** to the itd-22 lineage.\n", nil}, + {"resolved with a colon (itd-111, itd-93)", head + + "- **Explicit check naming** — resolved: `abcd version --check`.\n" + + "- **How much is templated?** RESOLVED: self-scaffold parity.\n", nil}, + {"a bold resolved or deferred lead (itd-60, itd-117)", head + + "- **Resolved — where the pass hooks.** Two points.\n" + + "- **Deferred to a follow-up intent**: detecting duplication.\n" + + "- **Deferred**: whether conventions migrate.\n", nil}, + {"an Open lead is a question whatever follows (itd-60)", head + + "- **Resolved — what built reality is.** Two layers.\n" + + "- **Open, and not gating scope** — whether this pass becomes a\n discipline. Deferred: the answer changes where.\n", + []string{"**Open, and not gating scope** — whether this pass becomes a"}}, + {"a pointer elsewhere is not a marker (itd-76)", head + + "- The share/ingest questions travel with [itd-126](../drafts/itd-126.md).\n", + []string{"The share/ingest questions travel with [itd-126](../drafts/itd-126.md)."}}, + {"a question that mentions deferral", head + + "- Should the check be deferred until the runner ships?\n- **Out of scope, recorded for clarity**: a workspace layer.\n", + []string{"Should the check be deferred until the runner ships?", "**Out of scope, recorded for clarity**: a workspace layer."}}, + {"an opener below the first item does not open the section", head + + "- Which runner?\n\n_All resolved at planning._\n", []string{"Which runner?"}}, + {"an opener that settles only some", head + + "_All but one resolved at planning:_\n\n- Which runner?\n", []string{"Which runner?"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := OpenQuestions(tc.content); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("OpenQuestions = %q, want %q", got, tc.want) + } + }) + } +} From 0789d3cb31af264bfe9f7f1a1c91da2e8b44c3d8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:55:36 +0100 Subject: [PATCH 023/107] =?UTF-8?q?chore:=20resolve=20iss-2609252053576085?= =?UTF-8?q?=20=E2=80=94=20open=20questions=20read=20the=20settled=20marker?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252053576085 Assisted-by: Claude:claude-opus-5-5 --- ...ld-s-open-questions-check-intent-openquestions-read.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md (65%) diff --git a/.abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md b/.abcd/work/issues/resolved/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md similarity index 65% rename from .abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md rename to .abcd/work/issues/resolved/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md index 9cb19a7c3..800af6dd6 100644 --- a/.abcd/work/issues/open/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md +++ b/.abcd/work/issues/resolved/iss-2609252053576085-the-build-s-open-questions-check-intent-openquestions-read.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "intent.OpenQuestions reads the section opener _All resolved …_ and an item's explicit resolved/deferred marker, and nothing else; the rule is the 2026-09-25 DECISIONS entry. itd-111 and itd-93 now pass; ten planned intents still refuse on items the record does not mark settled." +impact: fix +resolved_by: + commit: "b321386f" --- The build's open_questions check (intent.OpenQuestions, read by internal/core/implement/loop/check.go) counts every list item under '## Open Questions' as a question whatever it says, so it refuses records that follow the record's own settled convention: 12 of 50 planned intents carry list items there, and some are settled by their own words, itd-111's section opening '_All resolved or explicitly deferred at planning_' and itd-93's '_All four resolved_', itd-76's item marked '**explicitly deferred**', itd-60's items marked '**Resolved', itd-117's '**Deferred**' items. 'abcd build next' (itd-2609211116005482) would meet refusals nobody planned, and the ruling lives only in a lane report. Keep the check fail-closed, but read the markers the record actually uses. + +## Grounds + +- pursued: we expect the check to pass exactly the sections and items the record marks settled; shown wrong if an item led Open, a pointer, or a question mentioning deferral passes, or a marked item still refuses From 05009c9728bc18477540a13596374c33e4a39269 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:55:59 +0100 Subject: [PATCH 024/107] chore: capture two low notes from the sources verification review Refs: iss-2609252055532027 Refs: iss-2609252055533837 Refs: iss-2609250834251447 Assisted-by: Claude:claude-opus-5-5 --- ...this-checkout-now-builds-cmd-abcd-twice-once.md | 14 ++++++++++++++ ...ntry-for-the-sources-refresh-says-the-opt-in.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252055532027-every-commit-in-this-checkout-now-builds-cmd-abcd-twice-once.md create mode 100644 .abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md diff --git a/.abcd/work/issues/open/iss-2609252055532027-every-commit-in-this-checkout-now-builds-cmd-abcd-twice-once.md b/.abcd/work/issues/open/iss-2609252055532027-every-commit-in-this-checkout-now-builds-cmd-abcd-twice-once.md new file mode 100644 index 000000000..12189652f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252055532027-every-commit-in-this-checkout-now-builds-cmd-abcd-twice-once.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252055532027" +slug: "every-commit-in-this-checkout-now-builds-cmd-abcd-twice-once" +severity: "minor" +category: "ux" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".githooks/pre-commit" +--- + +Every commit in this checkout now builds ./cmd/abcd twice, once in .githooks/pre-commit for the sources refresh and once in commit-msg for the outbound lint (about 13 s on a cold build cache), where one shared build per commit would do; and in a linked worktree the pre-commit prints a sources skip line on every commit beside the existing linked-worktree notice (review2-sources 4 and 7). diff --git a/.abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md b/.abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md new file mode 100644 index 000000000..3f9ad03ce --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252055533837-the-decisions-entry-for-the-sources-refresh-says-the-opt-in.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252055533837" +slug: "the-decisions-entry-for-the-sources-refresh-says-the-opt-in" +severity: "minor" +category: "documentation" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/work/DECISIONS.md" +--- + +The DECISIONS entry for the sources refresh says the opt-in shape decides none of iss-2609250834251447's questions, but the template does take a provisional stance for the pre-commit refresh (opt-in; fail open on an unusable opt-in); the entry should call that half provisional (review2-sources 5). From f27aa4bdfc96efdc02928181654d9d5dba3fabcf Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:56:17 +0100 Subject: [PATCH 025/107] fix(implement): an unreadable run state is refused in the refusal shape A symlinked state.json or run directory failed closed, but as a generic error (exit 1) with no remedy, while every other unreadable state takes the loop's refusal shape. readStateIn and the run listing now refuse at the state step, naming the file and the remedy (exit 2). Refs: iss-2609252055468079 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/31-build.md | 4 +- ...p-cannot-read-for-a-filesystem-reason-a.md | 13 +++++ internal/core/implement/loop/loop_test.go | 52 +++++++++++++++++++ internal/core/implement/loop/state.go | 9 +++- 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md diff --git a/.abcd/development/brief/04-surfaces/31-build.md b/.abcd/development/brief/04-surfaces/31-build.md index bd11854d1..b44c89b3c 100644 --- a/.abcd/development/brief/04-surfaces/31-build.md +++ b/.abcd/development/brief/04-surfaces/31-build.md @@ -86,7 +86,9 @@ repository abcd manages has one, so a run is managed-only by construction. Each run directory is created one level at a time and proved real, the state file is replaced atomically inside an `os.Root`, and the reader decodes strictly, refusing an unknown field, another schema version, or a file stored under a run -id it does not name. +id it does not name. A state file or run directory that is a symlink, or that +the filesystem will not hand over, is refused in the same shape (exit 2, naming +the file and the remedy), never followed. The state holds the run's key, intent, spec and driver (the host session, by default); the window clock the pacing intent writes (`window_started_at`, diff --git a/.abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md b/.abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md new file mode 100644 index 000000000..bf5d05a11 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609252055468079" +slug: "a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a" +severity: "nitpick" +category: "inconsistency" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +A run state the loop cannot read for a filesystem reason, a symlinked state.json or a symlinked run-<id> directory under .abcd/.work.local/run/, fails closed but as a generic error (exit 1, 'reading …: not a regular file' or 'path escapes from parent') instead of the loop's refusal shape (step, reason, remedy; exit 2) that every other unreadable state takes (internal/core/implement/loop/state.go readStateIn and runIDs), so implement status/step/receipt and build report it with no remedy. diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go index d465a4b2f..f5ce1e169 100644 --- a/internal/core/implement/loop/loop_test.go +++ b/internal/core/implement/loop/loop_test.go @@ -621,6 +621,58 @@ func TestReadStateFailsClosed(t *testing.T) { } } +// TestASymlinkedRunStateIsRefusedInTheRefusalShape: a state file or a run +// directory that is a symlink out of the checkout fails closed as the loop's +// refusal — the step, the reason naming the file, the remedy — not as a +// generic error, from the direct read and from the listing alike. +func TestASymlinkedRunStateIsRefusedInTheRefusalShape(t *testing.T) { + t.Run("the state file", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(repo.Root(), filepath.FromSlash(StateRelPath(start.RunID))) + outside := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(outside, stateBytes(t, repo.Root(), start.RunID), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, path); err != nil { + t.Fatal(err) + } + _, err = ReadState(repo.Root(), start.RunID) + if r := mustRefusal(t, err); r.Step != "state" || !strings.Contains(r.Reason, StateRelPath(start.RunID)) { + t.Fatalf("want the state file named at the state step: %+v", r) + } + _, err = Runs(repo.Root()) + mustRefusal(t, err) + }) + t.Run("the run directory", func(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + start, err := Start(repo.Root(), "itd-10", Options{}) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(repo.Root(), filepath.FromSlash(RunRelDir), start.RunID) + outside := filepath.Join(t.TempDir(), start.RunID) + if err := os.Rename(dir, outside); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, dir); err != nil { + t.Fatal(err) + } + _, err = Runs(repo.Root()) + if r := mustRefusal(t, err); r.Step != "state" { + t.Fatalf("want the state step: %+v", r) + } + _, err = Start(repo.Root(), "itd-10", Options{}) + mustRefusal(t, err) + }) +} + // TestResolveNamesTheOnlyLiveRun: a call without a run id addresses the one // live run, and is refused naming them when there are several or none. func TestResolveNamesTheOnlyLiveRun(t *testing.T) { diff --git a/internal/core/implement/loop/state.go b/internal/core/implement/loop/state.go index a8e59ff53..e756c10ef 100644 --- a/internal/core/implement/loop/state.go +++ b/internal/core/implement/loop/state.go @@ -255,7 +255,11 @@ func readStateIn(root *os.Root, runID string) (State, error) { "name a run `abcd implement status` lists, or start one with `abcd build <itd-N>`") } if err != nil { - return State{}, fmt.Errorf("reading %s: %w", rel, err) + // A symlinked file or run directory, or one the filesystem will not + // hand over, fails closed in the refusal shape: it is not a file the + // loop wrote. + return State{}, refuse("state", "", "", fmt.Sprintf("%s cannot be read as the run's state: %v", rel, err), + "the loop writes a regular file in a real directory; restore that, or remove the run directory "+runRel(runID)) } var st State dec := json.NewDecoder(bytes.NewReader(data)) @@ -299,7 +303,8 @@ func runIDs(root *os.Root) ([]string, error) { return nil, nil } if err != nil { - return nil, fmt.Errorf("listing %s: %w", RunRelDir, err) + return nil, refuse("state", "", "", fmt.Sprintf("%s cannot be listed: %v", RunRelDir, err), + "the loop creates it as a real directory; restore that, or remove it") } defer f.Close() names, err := f.Readdirnames(-1) From e2c5c2164cb321a8a12f0d5216c34d44d4485f19 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:56:19 +0100 Subject: [PATCH 026/107] =?UTF-8?q?chore:=20resolve=20iss-2609252055468079?= =?UTF-8?q?=20=E2=80=94=20unreadable=20state=20refuses=20in=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252055468079 Assisted-by: Claude:claude-opus-5-5 --- ...tate-the-loop-cannot-read-for-a-filesystem-reason-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md (64%) diff --git a/.abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md b/.abcd/work/issues/resolved/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md similarity index 64% rename from .abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md rename to .abcd/work/issues/resolved/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md index bf5d05a11..0aac3e8d7 100644 --- a/.abcd/work/issues/open/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md +++ b/.abcd/work/issues/resolved/iss-2609252055468079-a-run-state-the-loop-cannot-read-for-a-filesystem-reason-a.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "readStateIn and runIDs refuse a state file or run directory the filesystem will not hand over (a symlink included) at the state step with the file and a remedy; TestASymlinkedRunStateIsRefusedInTheRefusalShape." +impact: fix +resolved_by: + commit: "f27aa4bd" --- A run state the loop cannot read for a filesystem reason, a symlinked state.json or a symlinked run-<id> directory under .abcd/.work.local/run/, fails closed but as a generic error (exit 1, 'reading …: not a regular file' or 'path escapes from parent') instead of the loop's refusal shape (step, reason, remedy; exit 2) that every other unreadable state takes (internal/core/implement/loop/state.go readStateIn and runIDs), so implement status/step/receipt and build report it with no remedy. + +## Grounds + +- pursued: we expect every unreadable run state to exit 2 in the refusal shape; shown wrong if a symlinked or unreadable state still surfaces as a generic exit 1 From c1c6d7b094bb23044006db2f36abd367dde6ae36 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:56:52 +0100 Subject: [PATCH 027/107] docs(spec): the loop spec lists its pieces as ## Steps, 1, 2 and 4 landed spc-2609202134338445 kept its eleven pieces under `## Scope`, which the spec store's step reader does not read, so `abcd build` on its own intent reported "lists no steps" and would have opened one lane for the whole spec, rebuilding the pieces lane 1 landed. The pieces are now its `## Steps`, each a numbered title with its design indented beneath, and pieces 1, 2 and 4 carry `landed: 7d3f3276`. `abcd intent ready` reports 11 steps listed, 3 landed. Assisted-by: Claude:claude-opus-5-5 --- ...le-intent-from-ready-to-delivered-witho.md | 114 ++++++++++-------- 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md b/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md index df232754b..c54f9c4e0 100644 --- a/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md +++ b/.abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md @@ -16,54 +16,67 @@ the CLI adapter when opted in. The ADR decision 6 owes (the `--auto-plan` substitute for the sign-off act, and the opt-in reversal of the host-delegated boundary) is minted before either path ships and is a delivery of this spec. -## Scope - -1. **The state file** under `.abcd/.work.local/run/<run-id>/state.json`: - run id, intent, lanes (each with branch, base sha, head sha, worktree - path, step, receipt path, PR number), window clock and - `next_eligible_at` (the pacing intent writes and reads those two), and - the run record accumulated as steps complete. One reader and one atomic - writer (`fsutil.WriteFileAtomic`); every verb below reads it first and - writes it last. -2. **The step interface** (decision 5, the default): `abcd build <itd-N>` - creates the run after the checks (criteria 1 and 2); `abcd implement step` - performs the next binary-owned step and, when a step needs an agent, - returns the brief path, the agent role and the receipt path it expects - (criterion 8); `abcd implement receipt <path>` verifies and advances - (criterion 4); `abcd implement status` renders the state. Every - invocation exits after one step (criterion 7). -3. **The process driver** (decision 5, opt-in by configuration): the same - loop calling itself through `step` and `receipt`, starting each agent - through the CLI adapter (`itd-2609201916056194`) and waiting for it; - named in the run record (criterion 9). -4. **The checks** (criteria 1, 2): the readiness gate, the claim sections, - the open-question count, the hold (prose until `iss-2609200830076665` - ships), and the peer reader (`itd-2609091416295622`). -5. **The brief renderer**: intent, spec, the conventions section of - `AGENTS.md` and the decision lines the intent cites, into one Markdown - file under the run directory (criterion 3). -6. **The lane**: worktree at `~/.abcd/worktrees/<root-sha>/<run>-<lane>` - in abcd's form (the store's verb once `itd-2609091014076309` ships; a - plain `git worktree add` until then), branch off the default branch. -7. **The receipt** (criterion 4): a file the agent writes naming its commits, - the definition of done's output and its report; the verifier checks the - commits exist on the branch and the report exists, and refuses otherwise. -8. **Validators** (criterion 5): the ruthless and security reviewer briefs - on the lane's diff, each a fresh agent; findings are applied by a fresh - implementer or rejected in the report; the fidelity request is completed - with the delivered range from base to head before the auditor runs. -9. **The landing** (criterion 6): `spec close` and `capture resolve` invoked - by the loop with the lane's commit, the pull request through the forge - client the repository already uses (`gh`), the merge rule from the - repository's ruleset, no push after arming, cleanup after the ancestor - check. -10. **The run record and transcripts** (criterion 10): the state file's - record rendered at the end, and `history capture` per transcript path - (one call once `iss-2609202046145653` ships). -11. **`--auto-plan`** (decision 6): the planning path run by the loop on a - draft whose decisions are all recorded, with the two adversarial reviews - as validator steps and the ADR's substitute checked before `plan`; - refused on anything less. +## Steps + +1. **The state file** + Under `.abcd/.work.local/run/<run-id>/state.json`: run id, intent, lanes + (each with branch, base sha, head sha, worktree path, step, receipt path, + PR number), window clock and `next_eligible_at` (the pacing intent writes + and reads those two), and the run record accumulated as steps complete. + One reader and one atomic writer (`fsutil.WriteFileAtomic`); every verb + below reads it first and writes it last. + - packages: internal/core/implement/loop + - landed: 7d3f3276 +2. **The step interface** + Decision 5, the default: `abcd build <itd-N>` creates the run after the + checks (criteria 1 and 2); `abcd implement step` performs the next + binary-owned step and, when a step needs an agent, returns the brief path, + the agent role and the receipt path it expects (criterion 8); `abcd + implement receipt <path>` verifies and advances (criterion 4); `abcd + implement status` renders the state. Every invocation exits after one step + (criterion 7). + - packages: internal/core/implement/loop, internal/surface/cli + - landed: 7d3f3276 +3. **The process driver** + Decision 5, opt-in by configuration: the same loop calling itself through + `step` and `receipt`, starting each agent through the CLI adapter + (`itd-2609201916056194`) and waiting for it; named in the run record + (criterion 9). +4. **The checks** + Criteria 1 and 2: the readiness gate, the claim sections, the + open-question count, the hold (prose until `iss-2609200830076665` ships), + and the peer reader (`itd-2609091416295622`). + - packages: internal/core/implement/loop, internal/core/intent, internal/core/peers + - landed: 7d3f3276 +5. **The brief renderer** + Intent, spec, the conventions section of `AGENTS.md` and the decision lines + the intent cites, into one Markdown file under the run directory + (criterion 3). +6. **The lane** + Worktree at `~/.abcd/worktrees/<root-sha>/<run>-<lane>` in abcd's form (the + store's verb once `itd-2609091014076309` ships; a plain `git worktree add` + until then), branch off the default branch. +7. **The receipt** + Criterion 4: a file the agent writes naming its commits, the definition of + done's output and its report; the verifier checks the commits exist on the + branch and the report exists, and refuses otherwise. +8. **Validators** + Criterion 5: the ruthless and security reviewer briefs on the lane's diff, + each a fresh agent; findings are applied by a fresh implementer or rejected + in the report; the fidelity request is completed with the delivered range + from base to head before the auditor runs. +9. **The landing** + Criterion 6: `spec close` and `capture resolve` invoked by the loop with + the lane's commit, the pull request through the forge client the + repository already uses (`gh`), the merge rule from the repository's + ruleset, no push after arming, cleanup after the ancestor check. +10. **The run record and transcripts** + Criterion 10: the state file's record rendered at the end, and `history + capture` per transcript path (one call once `iss-2609202046145653` ships). +11. **`--auto-plan`** + Decision 6: the planning path run by the loop on a draft whose decisions + are all recorded, with the two adversarial reviews as validator steps and + the ADR's substitute checked before `plan`; refused on anything less. ## Out of scope @@ -113,8 +126,9 @@ validators and ends the lane with that outcome when present. `abcd build ## Progress -The run builds the scope in four lanes; this section says which pieces have -landed and which remain. The spec stays open until the last lane closes it. +The run builds the steps above; this section says which have landed and which +remain, and the `landed:` lines under `## Steps` say the same to the loop. The +spec stays open until the last lane closes it. - **Landed (lane 1): pieces 1, 2 and 4.** The state file and its one reader and one atomic writer (`internal/core/implement/loop`), under From 3dc78a97669c172209d4e8447e1165c525c8ac84 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:55:35 +0100 Subject: [PATCH 028/107] feat(ahoy): every install value question carries core's plain-language help The install's value questions reached a person as bare enum values (oracle_backend: host-delegated|native|cli|api|mcp), so a front door had to invent what an oracle is and what each answer costs. Core now holds the canonical explanation for every value question it asks (visibility, docs_target, oracle_backend, scan_deep, the house-style question and each status-line element): what is being decided, and per answer what it does and what it asks of the person in keys, tools or cost. ahoy.HelpFor(key) returns it; the CLI prompter prints it above the unchanged question line, so piped answer streams still line up, and the plugin page tells the host agent to relay it verbatim. The oracle answers other than host-delegated say plainly that no adapter ships yet, since nothing reads the backend setting. The Prompter interface is unchanged: the help is looked up by the key the question already carries, so no implementation of the seam moves. Refs: iss-163 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 11 ++ commands/ahoy.md | 9 ++ internal/core/ahoy/prompt_help.go | 147 ++++++++++++++++++ internal/core/ahoy/prompt_help_test.go | 123 +++++++++++++++ internal/surface/cli/ahoy_prompt_help_test.go | 67 ++++++++ internal/surface/cli/cli.go | 12 ++ 6 files changed, 369 insertions(+) create mode 100644 internal/core/ahoy/prompt_help.go create mode 100644 internal/core/ahoy/prompt_help_test.go create mode 100644 internal/surface/cli/ahoy_prompt_help_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index d3e8508a4..06e29e9ae 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -342,6 +342,17 @@ agent drives the git-identity pin, the one approval no flag covers. Off a terminal each answer is echoed to the diagnostic stream, so a piped run leaves a transcript rather than a column of questions with no visible reply. +**Every value question carries its own explanation** (iss-163). A question that +picks one of several values (the repo visibility, the docs target, the oracle +backend, the deep-scan toggle, the house-style question and each status-line +element) is rendered with core's canonical help above it: what is being +decided, then what each answer means, including what it asks of the person in +keys, tools or cost. The oracle question defines an oracle before asking for +one, and says plainly that every answer but host-delegated is recorded without +changing how reviews run, because no other adapter ships. The words live in core, so every +front door shows the same explanation and none invents its own; the question +line itself is unchanged, so a piped answer stream lines up with it. + Answers that run out read as end-of-file, and end-of-file declines every confirm and takes the default for every prompt, so an unattended run adopts nothing it was not told to adopt. The cost is that a stdin held open and silent makes a diff --git a/commands/ahoy.md b/commands/ahoy.md index 2203199bc..80f948d67 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -123,6 +123,15 @@ than assuming. Under `set -o pipefail` the pipeline reports 141: `yes` takes SIGPIPE when abcd stops reading, by design — judge the run by abcd's own output and exit status, not the pipeline's. +**Every value question arrives explained.** A question that picks one of +several values (`visibility`, `docs_target`, `oracle_backend`, `scan_deep`, the +house-style question and each status-line element) is printed with abcd's own +explanation above it: one paragraph saying what is being decided, then one +line per answer saying what that answer means, including what it asks of the +user (keys, tools, cost). When you relay such a question, relay that +explanation verbatim with it; never describe an answer in your own words, and +never offer an answer the question does not list. + That is a channel for passing on an answer the user has GIVEN — ask first, then pipe; it is never a licence to answer on their behalf. Note that `yes |` approves EVERY question, so only reach for it once the user has agreed to all of diff --git a/internal/core/ahoy/prompt_help.go b/internal/core/ahoy/prompt_help.go new file mode 100644 index 000000000..b42efdc4f --- /dev/null +++ b/internal/core/ahoy/prompt_help.go @@ -0,0 +1,147 @@ +package ahoy + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/core/statusline" +) + +// ChoiceHelp explains one answer to an install question in plain language: +// what choosing it does to the person's repository or machine, and what it +// asks of them (keys, tools, cost) where it asks anything. +type ChoiceHelp struct { + Value string `json:"value"` + Meaning string `json:"meaning"` +} + +// PromptHelp is the canonical explanation of one value question the install +// asks through Prompter.Prompt: what is being decided, and what each answer +// means. It lives in core so every front door renders the same words and none +// has to invent them (iss-163) — a front door that describes an answer on its +// own can describe it wrongly, or circularly ("native — uses the native +// backend"). +// +// The prompt itself stays key, choices and default, so a scripted answer +// stream lines up with the questions as before; the help is looked up by the +// same key and rendered beside it. +type PromptHelp struct { + Key string `json:"key"` + About string `json:"about"` + Choices []ChoiceHelp `json:"choices"` +} + +// Meaning returns what answering value means, or "" for a value the question +// does not offer. +func (h PromptHelp) Meaning(value string) string { + for _, c := range h.Choices { + if c.Value == value { + return c.Meaning + } + } + return "" +} + +// noAdapterYet closes every oracle answer other than host-delegated: nothing in +// abcd reads the backend setting yet, so the answer is recorded and changes no +// review today. Saying so is the honest half of the explanation; an answer that +// promised a direct model call would describe a behaviour abcd does not have. +const noAdapterYet = " abcd does not ship this adapter yet, so the choice is recorded and reviews still go to the assistant you are working in." + +// promptHelp is the canonical help, one entry per value question. The choice +// order matches the order the question offers them. +var promptHelp = map[string]PromptHelp{ + "visibility": { + Key: "visibility", + About: "Whether abcd's records for this repository (its decisions, intents and issues, kept under .abcd/) " + + "are committed with your code or kept out of git. It decides what the block abcd writes into .gitignore contains.", + Choices: []ChoiceHelp{ + {Value: "private", Meaning: "the records under .abcd/ are committed with your code, so everyone who can see the repository shares them; " + + "only abcd's per-machine scratch space is kept out of git. Suits a repository whose code is not published."}, + {Value: "public", Meaning: "the whole .abcd/ folder is kept out of git, so the records stay on this machine and are not published with your code. " + + "If .abcd/ already holds committed records, only the per-machine scratch space is kept out, because git cannot hide a file it already tracks."}, + }, + }, + "docs_target": { + Key: "docs_target", + About: "Which conventions file, if any, gets a short block explaining how abcd works in this repository. " + + "AI coding assistants read these files at the start of every session; the block names abcd, so it goes only where you choose.", + Choices: []ChoiceHelp{ + {Value: "claude_md", Meaning: "writes the block into CLAUDE.md, and creates that file if it does not exist."}, + {Value: "agents_md", Meaning: "writes the block into AGENTS.md, the conventions file many AI coding assistants read, and creates it if it does not exist."}, + {Value: "both", Meaning: "writes the same block into both CLAUDE.md and AGENTS.md."}, + {Value: "skip", Meaning: "writes no block: abcd names itself in none of your conventions files. " + + "You can choose a file later with abcd ahoy install --docs-target."}, + }, + }, + "oracle_backend": { + Key: "oracle_backend", + About: "Which AI reviewer abcd uses. abcd calls it an oracle: the AI model asked to review or check your work, " + + "for example to read a change and say whether it is ready. The choice decides who runs that model, and so what it costs and which keys or tools it needs.", + Choices: []ChoiceHelp{ + {Value: "host-delegated", Meaning: "the AI assistant you are already working in runs every review. " + + "No API key, no extra tool and no cost beyond the assistant you already use. The recommended choice."}, + {Value: "native", Meaning: "abcd would call a model itself, through an adapter built into abcd; that needs the provider's API key, and the use is billed by that provider." + noAdapterYet}, + {Value: "cli", Meaning: "abcd would run a model's command-line tool installed on this machine; that tool must be installed and signed in, and its use may be billed." + noAdapterYet}, + {Value: "api", Meaning: "abcd would call a model provider's web API directly; that needs an API key, and each call is billed by the provider." + noAdapterYet}, + {Value: "mcp", Meaning: "abcd would reach a model through an MCP server (a standard way to connect tools to AI models) that you run or connect to; " + + "it needs that server set up, and whatever keys and cost the server brings." + noAdapterYet}, + }, + }, + "scan_deep": { + Key: "scan_deep", + About: "Whether this private repository also wants a deep secret scan with trufflehog, a scanner found on this machine " + + "that can check whether a leaked password or key still works. abcd's own built-in secret scan is not affected by the answer.", + Choices: []ChoiceHelp{ + {Value: "true", Meaning: "records that you want the deeper trufflehog scan (scan.deep in .abcd/config.json). " + + "No abcd check runs trufflehog yet, so today this records your preference and changes nothing else."}, + {Value: "false", Meaning: "keeps to abcd's built-in secret scan and records that choice, so the question is not asked again."}, + }, + }, + emDashPromptKey: { + Key: emDashPromptKey, + About: "How strict abcd's documentation check is about one house-style rule: an em dash (—) inside a list item. " + + "It is a matter of style, not correctness, so this repository chooses. The answer is written into " + + ".abcd/docs-lint.json, where it can be changed later.", + Choices: []ChoiceHelp{ + {Value: "blocking", Meaning: "an em dash in a list item fails the documentation check, so it must be fixed before the check passes."}, + {Value: "warning", Meaning: "an em dash in a list item is reported, but the documentation check still passes."}, + }, + }, +} + +// statusLineElementAbout says what each switchable element of the status line +// shows. The badge (element one) is not switchable and is never asked about. +var statusLineElementAbout = map[statusline.ElementKey]string{ + statusline.KeyRepo: "the name of the repository you are working in", + statusline.KeyBranch: "the git branch you are on", + statusline.KeyModel: "which AI model the assistant is using", + statusline.KeyContext: "how full the assistant's working memory for this conversation (its context window) is, as a percentage", + statusline.KeyFiveHour: "how much of your assistant plan's five-hour usage allowance is used, as a percentage", + statusline.KeySevenDay: "how much of your assistant plan's seven-day usage allowance is used, as a percentage", + statusline.KeyIntents: "how many intents (the planned pieces of work abcd tracks) the repository holds", + statusline.KeyIssues: "how many issues abcd's issue ledger holds for the repository", +} + +// HelpFor returns the canonical help for the value question keyed key, and +// false for a key the install does not ask about, so a front door renders +// nothing rather than a guess. +func HelpFor(key string) (PromptHelp, bool) { + if h, ok := promptHelp[key]; ok { + return h, true + } + if el, ok := strings.CutPrefix(key, elementPromptPrefix); ok { + shows, known := statusLineElementAbout[statusline.ElementKey(el)] + if !known { + return PromptHelp{}, false + } + return PromptHelp{ + Key: key, + About: "Whether abcd's status line shows " + shows + ".", + Choices: []ChoiceHelp{ + {Value: "on", Meaning: "shows it on the status line, in abcd-managed repositories."}, + {Value: "off", Meaning: "leaves it off the status line; switch it back on any time in " + statusline.SettingsDisplay + "."}, + }, + }, true + } + return PromptHelp{}, false +} diff --git a/internal/core/ahoy/prompt_help_test.go b/internal/core/ahoy/prompt_help_test.go new file mode 100644 index 000000000..c4a8911d8 --- /dev/null +++ b/internal/core/ahoy/prompt_help_test.go @@ -0,0 +1,123 @@ +package ahoy + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// choiceRecordingPrompter approves every confirm, answers the visibility +// question private (so the conditional deep-scan question is reached), takes +// the default everywhere else, and keeps every value question it was asked +// together with the choices it was offered. +type choiceRecordingPrompter struct { + asked map[string][]string +} + +func (p *choiceRecordingPrompter) Confirm(string) bool { return true } + +func (p *choiceRecordingPrompter) Prompt(key string, choices []string, def string) string { + p.asked[key] = append([]string(nil), choices...) + if key == "visibility" { + return "private" + } + return def +} + +// TestEveryInstallQuestionCarriesPlainLanguageHelp is iss-163's detector: a +// question core asks with bare enum values leaves the front door to invent +// what each answer means, and an invented description can be wrong or +// circular. It drives a real first install that reaches every value question +// core has — the three required configuration values, the conditional +// deep-scan question, the house-style question and the status-line element +// switches — and holds each one to canonical help: what is being decided, and +// a meaning for every choice offered, that is more than the value restated. +func TestEveryInstallQuestionCarriesPlainLanguageHelp(t *testing.T) { + setupHermetic(t) + harnessFixture(t, harnessSettingsWith("")) + // Deep scanning is only asked about when trufflehog is on PATH; keep the + // rest of PATH so git still resolves. + th := t.TempDir() + if err := os.WriteFile(filepath.Join(th, "trufflehog"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", th+string(os.PathListSeparator)+os.Getenv("PATH")) + repo := t.TempDir() + idMustGit(t, repo, "init") + + p := &choiceRecordingPrompter{asked: map[string][]string{}} + if _, err := Install(repo, InstallOptions{}, p); err != nil { + t.Fatal(err) + } + + // The drive must actually reach every family of question, or a passing run + // would say nothing about the one it skipped. + for _, want := range []string{"visibility", "docs_target", "oracle_backend", "scan_deep", emDashPromptKey, elementPromptPrefix + "repo"} { + if _, ok := p.asked[want]; !ok { + keys := make([]string, 0, len(p.asked)) + for k := range p.asked { + keys = append(keys, k) + } + sort.Strings(keys) + t.Fatalf("the install never asked %q, so its help is untested; asked %v", want, keys) + } + } + + for key, choices := range p.asked { + h, ok := HelpFor(key) + if !ok { + t.Errorf("%s: no canonical help, so a front door must invent what the question means", key) + continue + } + if h.Key != key { + t.Errorf("%s: help is keyed %q", key, h.Key) + } + if len(strings.Fields(h.About)) < 8 { + t.Errorf("%s: About %q does not say what is being decided", key, h.About) + } + if len(h.Choices) != len(choices) { + t.Errorf("%s: help explains %d choices, the question offers %d (%v)", key, len(h.Choices), len(choices), choices) + } + for _, c := range choices { + m := h.Meaning(c) + if len(strings.Fields(m)) < 5 { + t.Errorf("%s=%s: meaning %q is missing or too thin to explain the answer", key, c, m) + } + if strings.EqualFold(strings.TrimSpace(m), c) { + t.Errorf("%s=%s: meaning only restates the value", key, c) + } + } + } +} + +// TestOracleHelpDefinesTheOracleAndItsCosts pins the specific gap iss-163 +// names: the backend question must say what an oracle is, and every answer +// must state what it asks of the person (keys, tools, cost), not only its name. +func TestOracleHelpDefinesTheOracleAndItsCosts(t *testing.T) { + h, ok := HelpFor("oracle_backend") + if !ok { + t.Fatal("no help for oracle_backend") + } + if !strings.Contains(h.About, "oracle") || !strings.Contains(h.About, "AI model") { + t.Errorf("About does not define an oracle: %q", h.About) + } + for _, c := range oracleBackendChoices { + m := h.Meaning(c) + if !strings.Contains(m, "cost") && !strings.Contains(m, "billed") && !strings.Contains(m, "key") { + t.Errorf("%s: meaning states no consequence (cost, credentials): %q", c, m) + } + } +} + +// TestHelpForUnknownKeyIsAbsent keeps the lookup honest: a key core does not +// ask about has no help, so a front door renders nothing rather than a guess. +func TestHelpForUnknownKeyIsAbsent(t *testing.T) { + if _, ok := HelpFor("no_such_question"); ok { + t.Fatal("HelpFor invented help for an unknown key") + } + if _, ok := HelpFor(elementPromptPrefix + "no_such_element"); ok { + t.Fatal("HelpFor invented help for an unknown status-line element") + } +} diff --git a/internal/surface/cli/ahoy_prompt_help_test.go b/internal/surface/cli/ahoy_prompt_help_test.go new file mode 100644 index 000000000..45a148533 --- /dev/null +++ b/internal/surface/cli/ahoy_prompt_help_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "bufio" + "encoding/json" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestAhoyInstallRendersCoreHelpAboveEachValueQuestion is iss-163 at the front +// door: a value question is shown with core's canonical explanation of what is +// being decided and what every answer means, printed above the question line, +// so a person (or a host agent relaying the question) never has to invent it. +// The help travels on the diagnostic stream with the question, so the --json +// envelope on stdout stays clean. +func TestAhoyInstallRendersCoreHelpAboveEachValueQuestion(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t).Root() + t.Chdir(repo) + // --yes approves the categories, so the only questions left are the value + // questions no flag answered: visibility, the docs target, the oracle. + out, errOut, err := runCLIPipedStdinSplit(t, "private\n\n\n", "ahoy", "install", "--yes", "--adopt", "--json") + if err != nil { + t.Fatalf("install exited non-zero: %v\n%s\n%s", err, out, errOut) + } + if !json.Valid(out) { + t.Fatalf("stdout is not a clean JSON envelope:\n%s", out) + } + transcript := string(errOut) + for _, key := range []string{"visibility", "docs_target", "oracle_backend"} { + h, ok := ahoy.HelpFor(key) + if !ok { + t.Fatalf("core has no help for %s", key) + } + q := strings.Index(transcript, key+" (") + if q < 0 { + t.Fatalf("%s was not asked:\n%s", key, transcript) + } + about := strings.Index(transcript, h.About) + if about < 0 || about > q { + t.Errorf("%s: core's explanation is not printed above the question:\n%s", key, transcript) + } + for _, c := range h.Choices { + line := c.Value + " — " + c.Meaning + at := strings.Index(transcript, line) + if at < 0 || at > q { + t.Errorf("%s: the meaning of %q is not printed above the question", key, c.Value) + } + } + } +} + +// TestStdinPrompterRendersNoHelpForAnUnknownKey keeps the front door from +// inventing help: a key core has no help for is asked as the bare question. +func TestStdinPrompterRendersNoHelpForAnUnknownKey(t *testing.T) { + var buf strings.Builder + p := &stdinPrompter{r: bufio.NewReader(strings.NewReader("x\n")), w: &buf} + if got := p.Prompt("no_such_question", []string{"x", "y"}, "x"); got != "x" { + t.Fatalf("answer = %q", got) + } + if want := "no_such_question (x/y) [x]: x\n"; buf.String() != want { + t.Fatalf("rendered %q, want only the question and the echoed answer %q", buf.String(), want) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index b411a491b..b1bd4b04d 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3301,7 +3301,19 @@ func (p *stdinPrompter) Confirm(question string) bool { return line == "y" || line == "yes" } +// Prompt renders core's canonical explanation of the question above it (what +// is being decided, then what each answer means), so the person answering, or +// the host agent relaying the question to them, reads abcd's own words rather +// than inventing them (iss-163). The question line itself is unchanged, so a +// scripted answer stream and a transcript still line up with it. A key core +// has no help for is asked bare: the door never writes help of its own. func (p *stdinPrompter) Prompt(key string, choices []string, def string) string { + if h, ok := ahoy.HelpFor(key); ok { + fmt.Fprintf(p.w, "\n%s\n", h.About) + for _, c := range h.Choices { + fmt.Fprintf(p.w, " %s — %s\n", c.Value, c.Meaning) + } + } fmt.Fprintf(p.w, "%s (%s) [%s]: ", key, strings.Join(choices, "/"), def) line, _ := p.r.ReadString('\n') line = strings.TrimSpace(line) From 7cd4528bf6763f6c38de7bef8252bcfaa9da241a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:55:44 +0100 Subject: [PATCH 029/107] =?UTF-8?q?chore:=20resolve=20iss-163=20=E2=80=94?= =?UTF-8?q?=20install=20value=20questions=20carry=20core's=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-163 Assisted-by: Claude:claude-opus-5-5 --- .../plans/2026-08-15-facilitator-experience.md | 2 +- ...tall-config-prompts-are-unexplainable-to-a-first.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) rename .abcd/work/issues/{open => resolved}/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md (59%) diff --git a/.abcd/development/plans/2026-08-15-facilitator-experience.md b/.abcd/development/plans/2026-08-15-facilitator-experience.md index 422760c7f..3422c8b41 100644 --- a/.abcd/development/plans/2026-08-15-facilitator-experience.md +++ b/.abcd/development/plans/2026-08-15-facilitator-experience.md @@ -40,7 +40,7 @@ string it blocks on. Auto-merge never inherited; authorised per cycle. Ordering and item specs unchanged from that plan: -1. **[iss-163](../../work/issues/open/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md)** +1. **[iss-163](../../work/issues/resolved/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md)** — canonical per-choice help text lives in core; the foundation item. 2. **[iss-164](../../work/issues/open/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md)** (blocked by iss-163) — persona-readable result summaries. diff --git a/.abcd/work/issues/open/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md b/.abcd/work/issues/resolved/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md similarity index 59% rename from .abcd/work/issues/open/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md rename to .abcd/work/issues/resolved/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md index 55bdc07cf..f61583d90 100644 --- a/.abcd/work/issues/open/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md +++ b/.abcd/work/issues/resolved/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md @@ -7,6 +7,14 @@ category: "observation" source: "user-observation" found_during: "marketplace-install smoke test" found_at: "internal/core/ahoy/ahoy.go" +resolution: "Core holds canonical plain-language help for every install value question (ahoy.HelpFor): what is decided and what each answer means, costs and needs; the CLI prompter renders it above the question and the plugin page relays it verbatim." +impact: additive +resolved_by: + commit: "3dc78a97" --- -The ahoy install config prompts are unexplainable to a first-time user: core's Prompter interface passes bare enum values only (Prompt(key, choices, def) — e.g. oracle_backend: host-delegated|native|cli|api|mcp) with no per-choice help text, no definition of what an 'oracle' is, and no consequence statement (cost, credentials, tools needed). A host agent rendering the prompt has to invent descriptions, which can be wrong or circular ('native — uses the native backend'). Canonical plain-language descriptions for every choice belong in core, surfaced by every front door. \ No newline at end of file +The ahoy install config prompts are unexplainable to a first-time user: core's Prompter interface passes bare enum values only (Prompt(key, choices, def) — e.g. oracle_backend: host-delegated|native|cli|api|mcp) with no per-choice help text, no definition of what an 'oracle' is, and no consequence statement (cost, credentials, tools needed). A host agent rendering the prompt has to invent descriptions, which can be wrong or circular ('native — uses the native backend'). Canonical plain-language descriptions for every choice belong in core, surfaced by every front door. + +## Grounds + +- pursued: every value question the install asks now reaches the person with core's own explanation; shown wrong if a question is asked with a key HelpFor does not know, which TestEveryInstallQuestionCarriesPlainLanguageHelp drives a real install to catch From f0e7c837b6a4109f6e9848f91c32a9592b577f10 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:08:42 +0100 Subject: [PATCH 030/107] feat(ahoy): the install result explains itself in plain language The completion summary listed paths, gap ids and category names written for abcd's implementers, so the product thinker and the technical facilitator could not tell what changed, what needed action, or why it mattered. The install now returns a headline for its status and a summary: one item per kind of write, per declined category, for the outstanding required work and per optional step left undone, each with what it is, why it matters and what to do, and the refs it explains. The words live in core; the CLI text render leads with them and prints the exact record after as detail, and the plugin page tells the host agent to relay them. Every write now carries its kind through applyCtx.note(kind, path), so a write cannot reach the receipt without an explanation: the compiler refuses a call site that names none. The plugin-files-missing detection gap, which the record quotes, speaks plainly; the environment names stay in its fix hint, with what they are. InstallResult gains two exported fields (headline, summary); every existing field and the Prompter seam are unchanged. Refs: iss-164 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 11 + commands/ahoy.md | 11 +- internal/core/ahoy/ahoy.go | 11 + internal/core/ahoy/apply.go | 64 +++-- internal/core/ahoy/attribution_hook.go | 4 +- internal/core/ahoy/banlist_scaffold.go | 14 +- internal/core/ahoy/detect.go | 8 +- internal/core/ahoy/install_summary.go | 258 ++++++++++++++++++ internal/core/ahoy/install_summary_test.go | 159 +++++++++++ internal/core/ahoy/oracle_routing.go | 4 +- internal/core/ahoy/receipt.go | 8 +- internal/core/ahoy/statusline_apply.go | 8 +- internal/surface/cli/ahoy_prompt_help_test.go | 59 ++++ internal/surface/cli/cli.go | 12 + 14 files changed, 584 insertions(+), 47 deletions(-) create mode 100644 internal/core/ahoy/install_summary.go create mode 100644 internal/core/ahoy/install_summary_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 06e29e9ae..5e6f67fe1 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -353,6 +353,17 @@ changing how reviews run, because no other adapter ships. The words live in core front door shows the same explanation and none invents its own; the question line itself is unchanged, so a piped answer stream lines up with it. +**The result explains itself to the person who ran it** (iss-164). Beside the +exact record (every write, change, note, declined category, outstanding step and +optional step left undone), the install returns a one-sentence headline for its +status and a plain-language summary: one item per kind of write, per declined +category, for the required work still outstanding, and per optional step left +undone, each saying what it is, why it matters and what, if anything, to do, and +naming the paths or identifiers it explains. The words are core's, written for +the product thinker and the technical facilitator rather than abcd's +implementers, with no raw environment names; the text render leads with them and +prints the exact record after as detail. + Answers that run out read as end-of-file, and end-of-file declines every confirm and takes the default for every prompt, so an unattended run adopts nothing it was not told to adopt. The cost is that a stdin held open and silent makes a diff --git a/commands/ahoy.md b/commands/ahoy.md index 80f948d67..7830e9b55 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -73,9 +73,14 @@ them. If `folder_kind` is `unmanaged-folder`, note there is nothing to act on **This writes.** It applies the actionable gaps the detection pass found — the marker block (only where `--docs-target` names a conventions file; the -default, `skip`, names none), the `.abcd/` scaffolding, the owned `PATH` entry. Report the -returned `status`, what changed, and any `notes` — a note is a refusal, stating -something abcd deliberately did not do and why. The engine prompts before an +default, `skip`, names none), the `.abcd/` scaffolding, the owned `PATH` entry. Lead +the report with the returned `headline`, then each `summary` item in its own +three parts: `what` it is, `why` it matters, and the `action`, if any, the user +should take. These are abcd's own plain words for the product thinker and the +technical facilitator; relay them rather than rewording, and keep the `refs` +(the exact paths and identifiers each item explains) for anyone who asks. Then +report any `notes` — a note is a refusal, stating something abcd deliberately +did not do and why. The engine prompts before an ambiguous adoption, so surface any prompt to the user rather than answering it for them. diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index 9c6a98804..41ffa7eec 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -170,6 +170,17 @@ type InstallResult struct { // reported rather than assumed: a run that says "already up to date" while // leaving optional work on the table has to say so (iss-166). OptionalSkipped []string `json:"optional_skipped,omitempty"` + // Headline says in one plain sentence what the run amounted to, and Summary + // explains every reported item (each write, each declined kind of change, + // the required work still outstanding, the optional steps left undone) as + // what it is, why it matters and what, if anything, to do. They are for + // the person who ran the install; the fields above stay the exact record + // (iss-164). + Headline string `json:"headline"` + Summary []SummaryItem `json:"summary"` + // writeKinds runs parallel to Writes: what each write is, which the summary + // explains. Unexported, so the wire shape is unchanged. + writeKinds []writeKind } // ApplyResult is the outcome of one apply step. diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index da0a8d46d..bdceda63c 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -20,6 +20,17 @@ import ( // a re-run with zero required+resolvable gaps writes nothing and reports // "already_up_to_date". func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) { + res, err := install(cwd, opts, p) + if err != nil { + return res, err + } + res.explain() + return res, nil +} + +// install is Install without the person-facing summary, which Install composes +// once over whichever of the several outcomes below was reached. +func install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) { abs, err := filepath.Abs(cwd) if err != nil { return InstallResult{}, err @@ -201,6 +212,7 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) DeclinedCategories: declined, Notes: ac.notes, OptionalSkipped: optionalSkipped(opts, final.Gaps), + writeKinds: ac.writeKinds, }, nil } @@ -300,13 +312,15 @@ func adoptedBinTarget(pluginRoot string) string { // applyCtx threads the approved-category set and accumulated writes through the // ordered apply steps. type applyCtx struct { - cwd string - det DetectionResult - approved map[GapCategory]bool - overrides map[string]string - prompter Prompter - gapPresent map[string]bool - writes []string + cwd string + det DetectionResult + approved map[GapCategory]bool + overrides map[string]string + prompter Prompter + gapPresent map[string]bool + writes []string + // writeKinds runs parallel to writes: what each write is, for the summary. + writeKinds []writeKind changes []string // human-readable value changes an explicit override forced notes []string // loud refusals: what abcd deliberately did not do, and why autoYes bool // --yes: every category auto-approved without interaction @@ -401,7 +415,7 @@ func (a *applyCtx) stepIdentityPin() { return } if err := identity.WritePin(a.cwd, identity.Pin{Name: eff.Name, Email: eff.Email}); err == nil { - a.note(identity.PinRelPath) + a.note(writeIdentityPin, identity.PinRelPath) } } @@ -419,7 +433,7 @@ func (a *applyCtx) stepDependencies() { } tool := strings.TrimPrefix(strings.TrimSuffix(g.ID, "_missing"), "deps.") if !onPath(tool) { - a.note("dependency: " + g.FixHint) + a.note(writeScannerHint, "dependency: "+g.FixHint) } } } @@ -431,7 +445,7 @@ func (a *applyCtx) stepSkeleton() { } cfg := map[string]any{"meta": map[string]any{"schema_version": 1}} if err := writeConfig(a.cwd, cfg); err == nil { - a.note(configPath(a.cwd)) + a.note(writeSettings, configPath(a.cwd)) } } @@ -541,7 +555,7 @@ func (a *applyCtx) stepConfigValues() *InstallConfig { a.rollbackForced() return nil } - a.note(configPath(a.cwd)) + a.note(writeSettings, configPath(a.cwd)) return ic } @@ -683,7 +697,7 @@ func (a *applyCtx) stepVisibility(cfg *InstallConfig) { } wrote, err := applyVisibilityBlock(a.cwd, cfg.Visibility) if err == nil && wrote { - a.note(filepath.Join(a.cwd, ".gitignore")) + a.note(writeGitignore, filepath.Join(a.cwd, ".gitignore")) } // A narrowed public fence is said out loud (iss-255): the reader must learn // that the committed record tiers stay published, from the receipt rather @@ -713,7 +727,7 @@ func (a *applyCtx) stepHistory() { if a.approved[UserState] || a.approved[SafeAutocreate] { if wrote, err := bootstrapHistory(); err == nil && wrote { if root, e := historyRoot(); e == nil { - a.note(filepath.Join(root, "index.json")) + a.note(writeSessionStore, filepath.Join(root, "index.json")) } } } @@ -728,7 +742,7 @@ func (a *applyCtx) stepHistory() { repoDir := filepath.Join(root, sha) store, storeErr := history.Resolve(a.cwd, sha) if storeErr == nil && a.approved[SafeAutocreate] { - a.note(store.Records) + a.note(writeSessionStore, store.Records) } metaPath := filepath.Join(repoDir, "meta.json") if a.approved[UserState] && !fileExists(metaPath) { @@ -743,7 +757,7 @@ func (a *applyCtx) stepHistory() { "corpus": map[string]any{"transcripts": corpus}, } if err := writeJSON(metaPath, meta); err == nil { - a.note(metaPath) + a.note(writeSessionStore, metaPath) } } if a.approved[UserState] { @@ -754,7 +768,7 @@ func (a *applyCtx) stepHistory() { // it reads, so registerRepo's rewrite below writes the whole file back // clean, including entries this repo has nothing to do with. if scrubMetaCredential(metaPath) { - a.note(metaPath) + a.note(writeSessionStore, metaPath) a.changes = append(a.changes, "history meta.json: dropped a credential from the recorded remote URL — revoke the token, it has been on disk") } @@ -872,7 +886,7 @@ func (a *applyCtx) registerRepo(sha string) { } if wrote { if root, e2 := historyRoot(); e2 == nil { - a.note(filepath.Join(root, "index.json")) + a.note(writeSessionStore, filepath.Join(root, "index.json")) } } } @@ -904,7 +918,7 @@ func (a *applyCtx) stepMarker(cfg *InstallConfig) { for _, name := range markerTargets(target) { path := filepath.Join(a.cwd, name) if wrote, ok := installMarkerFile(path); ok && wrote { - a.note(path) + a.note(writeConventionsBlock, path) } } // Retract the block from files a narrowed docs-target override de-selected, @@ -912,7 +926,7 @@ func (a *applyCtx) stepMarker(cfg *InstallConfig) { for _, name := range a.markerRetract { path := filepath.Join(a.cwd, name) if wrote, ok := removeMarkerFile(path); ok && wrote { - a.note(path) + a.note(writeConventionsBlockRemoved, path) } } } @@ -1092,7 +1106,7 @@ func (a *applyCtx) installOwnedEntry(target string, kind binTargetKind) { return } a.recordEntry(target, want) - a.note(target) + a.note(writeCommandEntry, target) } // recordEntry stamps ~/.abcd/path-entry for the entry abcd just installed at @@ -1111,7 +1125,7 @@ func (a *applyCtx) recordEntry(target, shaHex string) { return } if p := userPathEntryPath(); p != "" { - a.note(p) + a.note(writeCommandEntry, p) } } @@ -1143,7 +1157,7 @@ func (a *applyCtx) installDevShim(target string, kind binTargetKind) { if err := fsutil.WriteFileAtomic(target, []byte(content), 0o755); err != nil { return } - a.note(target) + a.note(writeCommandEntry, target) } // stepPathEntry records the installed PATH entry in ~/.abcd/path-entry, for the @@ -1281,7 +1295,7 @@ func (a *applyCtx) installPinnedSymlink(target string, kind binTargetKind) { return } if err := os.Symlink(source, target); err == nil { - a.note(target) + a.note(writeCommandEntry, target) } else { a.refuse("could not write the PATH entry " + displayPath(target) + ": " + errText(err)) } @@ -1360,7 +1374,7 @@ func (a *applyCtx) stepRules() { // Contained through an os.Root opened at the repo: a committed `.abcd` ancestor // symlink must not land rules.json outside the working tree (GHSA-xrf8-4432-gw2f). if err := writeRepoJSON(a.cwd, rulesRelPath, rules); err == nil { - a.note(filepath.Join(a.cwd, ".abcd", "rules.json")) + a.note(writeRules, filepath.Join(a.cwd, ".abcd", "rules.json")) } } @@ -1389,7 +1403,7 @@ func (a *applyCtx) stepVersionStamp() { meta["project_name"] = a.det.RepoIdentity.Name cfgMap["meta"] = meta if err := writeConfig(a.cwd, cfgMap); err == nil { - a.note(configPath(a.cwd)) + a.note(writeSettings, configPath(a.cwd)) } } diff --git a/internal/core/ahoy/attribution_hook.go b/internal/core/ahoy/attribution_hook.go index 82b9ac472..fa7da858b 100644 --- a/internal/core/ahoy/attribution_hook.go +++ b/internal/core/ahoy/attribution_hook.go @@ -152,7 +152,7 @@ func (a *applyCtx) stepAttributionHook() { return } defer root.Close() - a.createContained(root, AttributionHookRelPath, attributionHookTemplate, 0o755, 0o755) + a.createContained(writeAttributionHook, root, AttributionHookRelPath, attributionHookTemplate, 0o755, 0o755) if a.attribution { a.recordAttributionOptIn() } @@ -188,5 +188,5 @@ func (a *applyCtx) recordAttributionOptIn() { a.changes = append(a.changes, receiptPath(a.cwd, "attribution opt-in not persisted ("+err.Error()+")")) return } - a.note(configPath(a.cwd)) + a.note(writeSettings, configPath(a.cwd)) } diff --git a/internal/core/ahoy/banlist_scaffold.go b/internal/core/ahoy/banlist_scaffold.go index 741afe898..5d27e860e 100644 --- a/internal/core/ahoy/banlist_scaffold.go +++ b/internal/core/ahoy/banlist_scaffold.go @@ -794,7 +794,7 @@ func (a *applyCtx) stepBanlist() { // have run, and a snapshot is by then a claim about a repo as it used to be. guardOwned := classifyGuardHook(root, GuardHookRelPath) == HookInstalled if a.has("banlist.hook_missing") { - if a.createContained(root, GuardHookRelPath, guardHookTemplate, 0o755, 0o755) { + if a.createContained(writeNameGuard, root, GuardHookRelPath, guardHookTemplate, 0o755, 0o755) { guardOwned = true } } @@ -809,7 +809,7 @@ func (a *applyCtx) stepBanlist() { // deliberately not raised (the pre-commit half's gap covers both), and apply must // still write the shim it just earned the right to write. if guardOwned && classifyGuardHook(root, GuardMergeHookRelPath) == HookAbsent { - a.createContained(root, GuardMergeHookRelPath, guardMergeHookTemplate, 0o755, 0o755) + a.createContained(writeNameGuard, root, GuardMergeHookRelPath, guardMergeHookTemplate, 0o755, 0o755) } // Never write a config abcd would immediately declare unenforceable — and never on // an answer git did not actually give. An unanswerable probe withholds the write @@ -821,7 +821,7 @@ func (a *applyCtx) stepBanlist() { // own severity, and a question whose answer would go nowhere is not asked. if a.has("banlist.public_family_missing") && publicPathIsWritable(a.cwd) { sev, note := a.emDashSeverity() - if a.createContained(root, banlist.PublicConfigRelPath, docsLintSeed(sev), 0o644, 0o755) && note != "" { + if a.createContained(writeDocsCheck, root, banlist.PublicConfigRelPath, docsLintSeed(sev), 0o644, 0o755) && note != "" { a.notes = append(a.notes, note) } } @@ -837,7 +837,7 @@ func (a *applyCtx) stepBanlist() { if a.has("banlist.private_stub_missing") && storePathIsSafe(a.cwd) { // 0700/0600: the directory holding private patterns is no more readable than // the patterns are, matching the store the banlist verbs write. - a.createContained(root, banlist.PrivateRelPath, []byte(privateStubContent()), 0o600, 0o700) + a.createContained(writePrivateNames, root, banlist.PrivateRelPath, []byte(privateStubContent()), 0o600, 0o700) } } @@ -866,7 +866,7 @@ func (a *applyCtx) pinHookEOL(root *os.Root) { if err := fsutil.WriteFileAtomicPreserveModeInRoot(root, gitattributesRelPath, appendEOLPin(data)); err != nil { return } - a.note(filepath.Join(a.cwd, gitattributesRelPath)) + a.note(writeNameGuard, filepath.Join(a.cwd, gitattributesRelPath)) } // appendEOLPin returns data with the canonical attribute appended, or data unchanged @@ -919,7 +919,7 @@ func lastLiveAttributeIsOurs(data []byte) bool { // Any failure — the file already exists (the ordinary idempotent no-op), an escaping // symlink, a permission fault — leaves the artefact unwritten and unnoted. Detection // reports it on the next pass rather than this run claiming a file it did not create. -func (a *applyCtx) createContained(root *os.Root, rel string, data []byte, perm, dirPerm os.FileMode) bool { +func (a *applyCtx) createContained(kind writeKind, root *os.Root, rel string, data []byte, perm, dirPerm os.FileMode) bool { if dir := path.Dir(rel); dir != "." { // MkdirAll on the PARENT, and it may create more than one level: every artefact // here sits at most two deep under the repo root (.githooks/, .abcd/.work.local/), @@ -951,6 +951,6 @@ func (a *applyCtx) createContained(root *os.Root, rel string, data []byte, perm, if err := root.Chmod(rel, perm); err != nil { return false } - a.note(filepath.Join(a.cwd, filepath.FromSlash(rel))) + a.note(kind, filepath.Join(a.cwd, filepath.FromSlash(rel))) return true } diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 49ffc2630..c4610d7d5 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -181,9 +181,11 @@ func detectPluginRoot(ok bool) []Gap { ID: "plugin.root_missing", Category: PluginOwned, Scope: "machine", - Title: "plugin root not resolvable", - Detail: "ABCD_PLUGIN_ROOT and CLAUDE_PLUGIN_ROOT are unset and the fallback found no plugin layout.", - FixHint: "Reinstall the abcd plugin, or set ABCD_PLUGIN_ROOT.", + // Plain words for the person, not the mechanism (iss-164): the two + // environment names are named only in the fix hint, with what they are. + Title: "abcd's plugin files were not found on this machine", + Detail: "abcd looked for the folder its plugin was installed into and found none, so it cannot check the automatic hooks that run it.", + FixHint: "Reinstall the abcd plugin in your AI assistant; or, to point abcd at a plugin folder by hand, set the ABCD_PLUGIN_ROOT environment variable to that folder (the assistant normally supplies it as CLAUDE_PLUGIN_ROOT).", }} } diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go new file mode 100644 index 000000000..e08c35071 --- /dev/null +++ b/internal/core/ahoy/install_summary.go @@ -0,0 +1,258 @@ +package ahoy + +import "slices" + +// SummaryItem explains one thing an install reports, for the person who ran it +// rather than for abcd's implementers (iss-164): what it is, why it matters for +// their work, and what, if anything, they should do. Refs names the exact +// entries of the result it explains (write paths, category names, step ids), so +// the plain words and the precise record can always be matched up. +type SummaryItem struct { + What string `json:"what"` + Why string `json:"why"` + Action string `json:"action"` + Refs []string `json:"refs,omitempty"` +} + +// writeKind says what one write of the install is, in the terms the summary +// explains it in. Every write carries one (applyCtx.note takes it), so no write +// reaches the person as a bare path. +type writeKind string + +const ( + writeSettings writeKind = "settings" + writeGitignore writeKind = "gitignore" + writeLocalTier writeKind = "local-tier" + writeNameGuard writeKind = "name-guard" + writePrivateNames writeKind = "private-names" + writeDocsCheck writeKind = "docs-check" + writeAttributionHook writeKind = "attribution-hook" + writeSessionStore writeKind = "session-store" + writeConventionsBlock writeKind = "conventions-block" + writeConventionsBlockRemoved writeKind = "conventions-block-removed" + writeCommandEntry writeKind = "command-entry" + writeStatusLine writeKind = "status-line" + writeRouting writeKind = "routing" + writeRules writeKind = "rules" + writeIdentityPin writeKind = "identity-pin" + writeScannerHint writeKind = "scanner-hint" +) + +// allWriteKinds is every kind, in the order the summary lists them: the +// repository's own files first, then this machine, then the optional extras. +var allWriteKinds = []writeKind{ + writeSettings, writeGitignore, writeLocalTier, writeNameGuard, writePrivateNames, + writeDocsCheck, writeAttributionHook, writeRules, writeConventionsBlock, + writeConventionsBlockRemoved, writeIdentityPin, writeCommandEntry, writeSessionStore, + writeStatusLine, writeRouting, writeScannerHint, +} + +// writeKindHelp is the plain-language explanation of each kind of write. +var writeKindHelp = map[writeKind]SummaryItem{ + writeSettings: { + What: "Saved this repository's abcd settings in .abcd/config.json.", + Why: "Later runs read your answers from there instead of asking again.", + Action: "Nothing. To change a setting, run abcd ahoy install with its option, for example --visibility public.", + }, + writeGitignore: { + What: "Told git which abcd files stay on this machine, in a fenced block in .gitignore.", + Why: "Your private notes and scratch files, and for a public repository abcd's records, are never committed by accident.", + Action: "Nothing. Leave the fenced block as it is; abcd keeps it up to date.", + }, + writeLocalTier: { + What: "Created .abcd/.work.local/, a private working folder for this machine only.", + Why: "abcd keeps handover notes, logs and scratch work there, and git ignores it.", + Action: "Nothing to do.", + }, + writeNameGuard: { + What: "Added a check that runs before every commit and merge and stops one that contains a name you have banned.", + Why: "Names you want kept private, such as people, clients or machines, never reach the repository's history.", + Action: "Nothing now. To ban a name, use abcd banlist add.", + }, + writePrivateNames: { + What: "Created an empty private list of banned names for this machine, which is never committed.", + Why: "It is where names too sensitive to write into the repository go, and the pre-commit check reads it.", + Action: "Add names to it with abcd banlist add when you have any.", + }, + writeDocsCheck: { + What: "Set up the documentation check's settings in .abcd/docs-lint.json.", + Why: "The check flags writing in your documentation that is out of date or breaks the house style, and this file says how strict it is.", + Action: "Nothing. Edit the file if you want a rule stricter or looser.", + }, + writeAttributionHook: { + What: "Added a commit-message prompt that asks every commit to say whether an AI tool helped write it.", + Why: "It keeps an honest record of AI assistance in the repository's history.", + Action: "Nothing. Answer the prompt when you commit.", + }, + writeRules: { + What: "Created .abcd/rules.json, which switches abcd's working-rule reminders on or off for this repository.", + Why: "abcd reminds your AI assistant of the relevant rules only when a request touches them; this file lets the repository turn a rule off or add its own.", + Action: "Nothing, unless you want to change a rule.", + }, + writeConventionsBlock: { + What: "Added a short block describing how abcd works here to the conventions file you chose.", + Why: "AI assistants read that file at the start of every session, so they follow this repository's abcd conventions.", + Action: "Nothing. Do not edit inside the block; abcd rewrites it on each run.", + }, + writeConventionsBlockRemoved: { + What: "Removed abcd's block from a conventions file you no longer chose.", + Why: "abcd names itself only in the files you pick.", + Action: "Nothing to do.", + }, + writeIdentityPin: { + What: "Recorded the git name and email that commit to this repository.", + Why: "abcd can then warn when a commit is about to be made under a different identity, such as an agent's.", + Action: "Nothing, unless the recorded name or email is wrong; then run abcd ahoy install again.", + }, + writeCommandEntry: { + What: "Made the abcd command available in your terminal.", + Why: "You can run abcd directly, and the plugin's automatic checks can find it.", + Action: "Nothing, unless a note below says its folder is not on your command path; then follow that note.", + }, + writeSessionStore: { + What: "Registered this repository on this machine, in abcd's folder in your home directory.", + Why: "abcd can keep a redacted record of your working sessions for this repository outside the repository itself.", + Action: "Nothing to do.", + }, + writeStatusLine: { + What: "Set up abcd's status line in your AI assistant.", + Why: "In abcd repositories the line shows whether abcd is active and whose answer the work is waiting on.", + Action: "Nothing. Switch parts of it off in ~/.abcd/statusline.json, or remove it with abcd ahoy uninstall.", + }, + writeRouting: { + What: "Saved which size of AI model each of abcd's review steps asks for.", + Why: "Larger models cost more; the table keeps the expensive ones for the steps that need them.", + Action: "Nothing. Edit the saved table if you want a different split.", + }, + writeScannerHint: { + What: "Listed the command that installs an optional extra scanner; abcd did not run it.", + Why: "abcd's own checks work without it, and the extra scanner looks deeper for leaked secrets.", + Action: "If you want the deeper scan, run the command listed under the written items; abcd never runs an installer for you.", + }, +} + +// declinedCategoryHelp explains each kind of change the person declined. +var declinedCategoryHelp = map[GapCategory]SummaryItem{ + SafeAutocreate: { + What: "You declined creating abcd's folders and starter files in this repository.", + Why: "abcd has nowhere to keep its records here until they exist.", + Action: "Run abcd ahoy install again and answer y to create them.", + }, + ConfigChange: { + What: "You declined saving this repository's abcd settings.", + Why: "abcd asks the same questions again on every run until they are saved.", + Action: "Run abcd ahoy install again and answer y, or pass the settings as options.", + }, + PluginOwned: { + What: "You declined writing abcd's description block into your conventions file.", + Why: "Your AI assistant will not be told how abcd works in this repository.", + Action: "Run abcd ahoy install again and answer y if you want the block.", + }, + Dependency: { + What: "You declined being shown how to install the optional extra scanners.", + Why: "abcd's own checks still run; only the deeper extra scans are missing.", + Action: "Nothing, unless you want them; then run abcd ahoy install again and answer y.", + }, + UserState: { + What: "You declined registering this repository on this machine.", + Why: "abcd cannot keep a record of your working sessions for this repository until it is registered.", + Action: "Run abcd ahoy install again and answer y to register it.", + }, + StatusLine: { + What: "You declined abcd's status line.", + Why: "Your assistant's status line stays exactly as it was.", + Action: "Nothing. Run abcd ahoy install again if you change your mind.", + }, + OracleRouting: { + What: "You declined abcd's suggested table of which AI model size each review step uses.", + Why: "Review steps use whatever model your assistant picks by default.", + Action: "Nothing. Run abcd ahoy install again if you want the table.", + }, +} + +// optionalSkippedHelp explains each optional step an unattended run left alone. +var optionalSkippedHelp = map[string]SummaryItem{ + OptionalPinGapID: { + What: "Recording who commits to this repository was left for you to confirm.", + Why: "It would record whatever git name and email happen to be set, which in an unattended run may be an agent's.", + Action: "Run abcd ahoy install without --yes and answer y if the name and email shown are yours.", + }, + StatusLineOfferGapID: { + What: "abcd's status line was not set up.", + Why: "It changes a setting of your AI assistant that applies everywhere, so it needs your own yes.", + Action: "Run abcd ahoy install without --yes and answer the status-line question.", + }, + OracleRoutingMachineGapID: { + What: "abcd's suggested table of which AI model size each review step uses was not saved for this machine.", + Why: "The table decides which model, and so what cost, each review step asks for, so it needs your own yes.", + Action: "Run abcd ahoy install without --yes and answer the question about the table.", + }, + OracleRoutingRepoGapID: { + What: "abcd's suggested table of which AI model size each review step uses was not saved for this repository.", + Why: "The table decides which model, and so what cost, each review step asks for, so it needs your own yes.", + Action: "Run abcd ahoy install without --yes and answer the question about the table.", + }, +} + +// remainingHelp explains the required work a run left outstanding. +var remainingHelp = SummaryItem{ + What: "Some required set-up steps are still not done.", + Why: "abcd does not work fully in this repository until they are.", + Action: "Run abcd ahoy install again and answer y to each question; abcd ahoy doctor lists the steps one by one.", +} + +// statusHeadline is the one sentence that opens the summary for each status. +var statusHeadline = map[string]string{ + "already_up_to_date": "abcd was already set up in this repository, so nothing needed changing.", + "clean": "abcd is set up in this repository.", + "partial": "abcd is only partly set up in this repository; the items below say what is missing and how to finish.", + "aborted": "Nothing was set up: abcd was not added to this folder.", + "refused": "Nothing was set up: abcd stopped before changing anything, and the notes say why.", +} + +// explain composes Headline and Summary from the exact record the rest of the +// result carries. It adds nothing the record does not say; it says it plainly. +func (r *InstallResult) explain() { + r.Headline = statusHeadline[r.Status] + r.Summary = []SummaryItem{} + + refs := map[writeKind][]string{} + for i, w := range r.Writes { + k := writeScannerHint + if i < len(r.writeKinds) { + k = r.writeKinds[i] + } + if !slices.Contains(refs[k], w) { + refs[k] = append(refs[k], w) + } + } + for _, k := range allWriteKinds { + if len(refs[k]) == 0 { + continue + } + it := writeKindHelp[k] + it.Refs = refs[k] + r.Summary = append(r.Summary, it) + } + for _, c := range r.DeclinedCategories { + it, ok := declinedCategoryHelp[GapCategory(c)] + if !ok { + continue + } + it.Refs = []string{c} + r.Summary = append(r.Summary, it) + } + if len(r.Remaining) > 0 { + it := remainingHelp + it.Refs = append([]string(nil), r.Remaining...) + r.Summary = append(r.Summary, it) + } + for _, id := range r.OptionalSkipped { + it, ok := optionalSkippedHelp[id] + if !ok { + continue + } + it.Refs = []string{id} + r.Summary = append(r.Summary, it) + } +} diff --git a/internal/core/ahoy/install_summary_test.go b/internal/core/ahoy/install_summary_test.go new file mode 100644 index 000000000..56bcaa13a --- /dev/null +++ b/internal/core/ahoy/install_summary_test.go @@ -0,0 +1,159 @@ +package ahoy + +import ( + "regexp" + "strings" + "testing" +) + +// envName matches a raw environment-variable name (ABCD_PLUGIN_ROOT, +// CLAUDE_PLUGIN_ROOT): an all-caps word joined by underscores. +var envName = regexp.MustCompile(`\b[A-Z][A-Z0-9]*_[A-Z0-9_]+\b`) + +// insiderWords are the implementer's vocabulary iss-164 found leading the +// completion summary. A person reading what the install did should meet none of +// it: each names a mechanism, not what changed for them. +var insiderWords = []string{"managed repo", "marker block", "canonical", "identity gate", "plugin root", "machine-scope", "gap", "history index", "registry"} + +// assertPlainItem holds one summary item to iss-164's frame: what this is, why +// it matters, what (if anything) to do — each present, and each free of raw +// environment names and insider vocabulary. +func assertPlainItem(t *testing.T, it SummaryItem) { + t.Helper() + for field, text := range map[string]string{"what": it.What, "why": it.Why, "action": it.Action} { + if len(strings.Fields(text)) < 3 { + t.Errorf("item %q: %s is missing or too thin: %q", it.What, field, text) + } + if m := envName.FindString(text); m != "" { + t.Errorf("item %q: %s names the raw environment variable %s: %q", it.What, field, m, text) + } + for _, w := range insiderWords { + if strings.Contains(strings.ToLower(text), w) { + t.Errorf("item %q: %s uses insider vocabulary %q: %q", it.What, field, w, text) + } + } + } +} + +// refsOf collects every reference the summary explains. +func refsOf(items []SummaryItem) map[string]bool { + out := map[string]bool{} + for _, it := range items { + for _, r := range it.Refs { + out[r] = true + } + } + return out +} + +// TestInstallSummaryExplainsEveryWrite is iss-164's detector for the write +// half: every artefact a first install reports writing is explained by a +// summary item in plain language, and the result leads with a sentence saying +// what the run amounted to. +func TestInstallSummaryExplainsEveryWrite(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + idMustGit(t, repo, "init") + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if len(res.Writes) == 0 { + t.Fatal("precondition: a first install writes something") + } + if len(strings.Fields(res.Headline)) < 4 { + t.Errorf("headline %q does not say what the run amounted to", res.Headline) + } + refs := refsOf(res.Summary) + for _, w := range res.Writes { + if !refs[w] { + t.Errorf("write %q is reported with no plain-language explanation", w) + } + } + for _, id := range res.OptionalSkipped { + if !refs[id] { + t.Errorf("optional step %q left undone is reported with no explanation", id) + } + } + for _, it := range res.Summary { + assertPlainItem(t, it) + } +} + +// TestInstallSummaryExplainsDeclinedAndRemainingWork covers the other half: a +// run the person declined says, per declined kind of change, what was not done +// and how to do it later, and the required work still outstanding is named as +// such rather than as a bare list of identifiers. +func TestInstallSummaryExplainsDeclinedAndRemainingWork(t *testing.T) { + setupHermetic(t) + repo := t.TempDir() + idMustGit(t, repo, "init") + adopt := true + res, err := Install(repo, InstallOptions{Adopt: &adopt}, stubPrompter{confirm: false}) + if err != nil { + t.Fatal(err) + } + if len(res.DeclinedCategories) == 0 || len(res.Remaining) == 0 { + t.Fatalf("precondition: declining every question leaves declined and remaining work; got %+v", res) + } + refs := refsOf(res.Summary) + for _, c := range res.DeclinedCategories { + if !refs[c] { + t.Errorf("declined category %q is reported with no explanation", c) + } + } + for _, id := range res.Remaining { + if !refs[id] { + t.Errorf("outstanding step %q is reported with no explanation", id) + } + } + for _, it := range res.Summary { + assertPlainItem(t, it) + } +} + +// TestEveryWriteKindIsExplained keeps the table whole: a kind of write added +// without an explanation would reach the person as a bare path. +func TestEveryWriteKindIsExplained(t *testing.T) { + for _, k := range allWriteKinds { + it, ok := writeKindHelp[k] + if !ok { + t.Errorf("write kind %q has no explanation", k) + continue + } + assertPlainItem(t, it) + } + for _, c := range []GapCategory{SafeAutocreate, ConfigChange, PluginOwned, Dependency, UserState, StatusLine, OracleRouting} { + it, ok := declinedCategoryHelp[c] + if !ok { + t.Errorf("category %q has no explanation for when it is declined", c) + continue + } + assertPlainItem(t, it) + } + for _, id := range []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID} { + it, ok := optionalSkippedHelp[id] + if !ok { + t.Errorf("optional step %q has no explanation", id) + continue + } + assertPlainItem(t, it) + } +} + +// TestPluginFilesMissingGapSpeaksPlainly is the detection half iss-164 quotes: +// the gap a person meets when abcd cannot find its plugin files led with +// "plugin root not resolvable" and two raw environment names. The title and +// detail say what is wrong in plain language; the fix hint may name the +// setting, but only with what it is. +func TestPluginFilesMissingGapSpeaksPlainly(t *testing.T) { + g := detectPluginRoot(false)[0] + for field, text := range map[string]string{"title": g.Title, "detail": g.Detail} { + if m := envName.FindString(text); m != "" { + t.Errorf("%s names the raw environment variable %s: %q", field, m, text) + } + if strings.Contains(strings.ToLower(text), "plugin root") { + t.Errorf("%s uses insider vocabulary: %q", field, text) + } + } +} diff --git a/internal/core/ahoy/oracle_routing.go b/internal/core/ahoy/oracle_routing.go index f53a44124..1d747b2c1 100644 --- a/internal/core/ahoy/oracle_routing.go +++ b/internal/core/ahoy/oracle_routing.go @@ -137,7 +137,7 @@ func (a *applyCtx) writeMachineRouting(body []byte) { a.refuse("could not write ~/.abcd/oracle-routing.json (" + errText(err) + "); the routing was not accepted.") return } - a.note(p) + a.note(writeRouting, p) } // writeRepoRouting writes the repository table inside an os.Root at the @@ -170,7 +170,7 @@ func (a *applyCtx) writeRepoRouting(body []byte) { a.refuse("could not write " + rel + " (" + errText(err) + "); the repository's routing was not accepted.") return } - a.note(filepath.Join(a.cwd, filepath.FromSlash(rel))) + a.note(writeRouting, filepath.Join(a.cwd, filepath.FromSlash(rel))) } // proposalTable renders the bundled proposal in the routing file's own shape, diff --git a/internal/core/ahoy/receipt.go b/internal/core/ahoy/receipt.go index da2d84dbe..aabd5a95f 100644 --- a/internal/core/ahoy/receipt.go +++ b/internal/core/ahoy/receipt.go @@ -15,8 +15,14 @@ import ( // nothing itself: a step added later cannot put a home directory or a username // on a receipt a user pastes into an issue, because the scrub is not a thing a // step has to remember to do. -func (a *applyCtx) note(path string) { +// +// The kind says what the write IS for the person who ran the install, so the +// completion summary can explain it rather than list a path (iss-164). It is a +// parameter, not a lookup on the path, so a write cannot reach the receipt +// without one. +func (a *applyCtx) note(kind writeKind, path string) { a.writes = append(a.writes, receiptPath(a.cwd, path)) + a.writeKinds = append(a.writeKinds, kind) } // receiptPath renders one receipt entry with the two roots that carry developer diff --git a/internal/core/ahoy/statusline_apply.go b/internal/core/ahoy/statusline_apply.go index 88cdc503f..ba86a2e93 100644 --- a/internal/core/ahoy/statusline_apply.go +++ b/internal/core/ahoy/statusline_apply.go @@ -194,9 +194,9 @@ func (a *applyCtx) wireStatusLine(hs harnessSettings, entry string, switches map return } if settingBytes != nil { - a.note(settingPath) + a.note(writeStatusLine, settingPath) } - a.note(hs.path) + a.note(writeStatusLine, hs.path) } // repairStatusLine closes the dangling gap: the harness's command names an abcd @@ -238,7 +238,7 @@ func (a *applyCtx) repairStatusLine() { a.refuse("could not write " + displayPath(hs.path) + " (" + errText(err) + "); the dangling status line was left as it is.") return } - a.note(hs.path) + a.note(writeStatusLine, hs.path) } // uninstallStatusLine is the uninstall half: a status line that is abcd's is @@ -440,5 +440,5 @@ func (a *applyCtx) stepLocalTier() { ". abcd never reaches the local tier through a symlink; remove what is there and re-run `abcd ahoy install`.") return } - a.note(filepath.Join(a.cwd, filepath.FromSlash(localTierRelPath))) + a.note(writeLocalTier, filepath.Join(a.cwd, filepath.FromSlash(localTierRelPath))) } diff --git a/internal/surface/cli/ahoy_prompt_help_test.go b/internal/surface/cli/ahoy_prompt_help_test.go index 45a148533..e0fe4f961 100644 --- a/internal/surface/cli/ahoy_prompt_help_test.go +++ b/internal/surface/cli/ahoy_prompt_help_test.go @@ -65,3 +65,62 @@ func TestStdinPrompterRendersNoHelpForAnUnknownKey(t *testing.T) { t.Fatalf("rendered %q, want only the question and the echoed answer %q", buf.String(), want) } } + +// TestAhoyInstallTextLeadsWithThePlainSummary is iss-164 at the front door: the +// text render opens with core's headline and its plain-language items (what, +// why, what to do) before the exact record of paths, so a person reads what +// changed for them first and the implementer's detail after. +func TestAhoyInstallTextLeadsWithThePlainSummary(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t).Root() + t.Chdir(repo) + args := []string{"ahoy", "install", "--yes", "--adopt", "--visibility", "private", "--docs-target", "both", + "--oracle-backend", "host-delegated", "--scan-deep", "false"} + out, errOut, err := runCLIPipedStdinSplit(t, "", append(args, "--json")...) + if err != nil { + t.Fatalf("install exited non-zero: %v\n%s\n%s", err, out, errOut) + } + var res ahoy.InstallResult + if err := json.Unmarshal(out, &res); err != nil { + t.Fatal(err) + } + if res.Headline == "" || len(res.Summary) == 0 { + t.Fatalf("the JSON envelope carries no plain summary: %s", out) + } + + // A second repository, rendered as text, so the first run's writes do not + // turn this one into an up-to-date no-op. + repo2 := gittest.NewRepo(t).Root() + t.Chdir(repo2) + text, errOut, err := runCLIPipedStdinSplit(t, "", args...) + if err != nil { + t.Fatalf("install exited non-zero: %v\n%s\n%s", err, text, errOut) + } + s := string(text) + firstDetail := strings.Index(s, " wrote: ") + if firstDetail < 0 { + t.Fatalf("no write detail in the text render:\n%s", s) + } + if at := strings.Index(s, res.Headline); at < 0 || at > firstDetail { + t.Errorf("the headline does not lead the render:\n%s", s) + } + // The machine-level writes (the command entry, the session store) land on + // the first run only, so the second repository's summary is the first's + // less those; every item it does print must come whole and before detail. + shown := 0 + for _, it := range res.Summary { + at := strings.Index(s, it.What) + if at < 0 { + continue + } + shown++ + for _, part := range []string{it.What, it.Why, it.Action} { + if at := strings.Index(s, part); at < 0 || at > firstDetail { + t.Errorf("summary text %q is not printed before the detail:\n%s", part, s) + } + } + } + if shown < 5 { + t.Errorf("only %d summary items were printed:\n%s", shown, s) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index b1bd4b04d..809ec0f58 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -2892,6 +2892,18 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { fmt.Fprintf(w, "abcd ahoy install — %s\n", res.Status) + // Core's plain summary first (iss-164): what changed for the + // person, why it matters and what to do. The exact record of + // paths and identifiers follows as detail. + if res.Headline != "" { + fmt.Fprintf(w, "\n%s\n", res.Headline) + } + for _, it := range res.Summary { + fmt.Fprintf(w, "\n - %s\n %s\n %s\n", it.What, it.Why, it.Action) + } + if len(res.Summary) > 0 { + fmt.Fprint(w, "\ndetail:\n") + } for _, c := range res.Changes { fmt.Fprintf(w, " changed: %s\n", c) } From 7c01034482fde0b9c18e361a87c8b52f36b26127 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:08:50 +0100 Subject: [PATCH 031/107] =?UTF-8?q?chore:=20resolve=20iss-164=20=E2=80=94?= =?UTF-8?q?=20install=20result=20explains=20itself=20in=20plain=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-164 Assisted-by: Claude:claude-opus-5-5 --- .../plans/2026-08-15-facilitator-experience.md | 2 +- ...tall-completion-summary-is-written-for-abcd-s-im.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) rename .abcd/work/issues/{open => resolved}/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md (62%) diff --git a/.abcd/development/plans/2026-08-15-facilitator-experience.md b/.abcd/development/plans/2026-08-15-facilitator-experience.md index 3422c8b41..e80871890 100644 --- a/.abcd/development/plans/2026-08-15-facilitator-experience.md +++ b/.abcd/development/plans/2026-08-15-facilitator-experience.md @@ -42,7 +42,7 @@ Ordering and item specs unchanged from that plan: 1. **[iss-163](../../work/issues/resolved/iss-163-the-ahoy-install-config-prompts-are-unexplainable-to-a-first.md)** — canonical per-choice help text lives in core; the foundation item. -2. **[iss-164](../../work/issues/open/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md)** +2. **[iss-164](../../work/issues/resolved/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md)** (blocked by iss-163) — persona-readable result summaries. 3. **[itd-63](../intents/planned/itd-63-setup-wizard-explains-installs.md)** — the intent frame A1/A2 deliver into. Lifecycle first: planned but diff --git a/.abcd/work/issues/open/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md b/.abcd/work/issues/resolved/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md similarity index 62% rename from .abcd/work/issues/open/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md rename to .abcd/work/issues/resolved/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md index 424c50dbb..bd2822d00 100644 --- a/.abcd/work/issues/open/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md +++ b/.abcd/work/issues/resolved/iss-164-the-ahoy-install-completion-summary-is-written-for-abcd-s-im.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "marketplace-install smoke test" found_at: "internal/core/ahoy" blocked_by: [iss-163] +resolution: "The install returns a plain-language headline and summary from core (what/why/action per write kind, declined category, outstanding work and optional step), each write carrying its kind; the CLI leads with it and the plugin page relays it." +impact: additive +resolved_by: + commit: "f0e7c837" --- -The ahoy install completion summary is written for abcd's implementers, not its personas: it leads with insider vocabulary ('managed repo', 'canonical marker blocks', 'the identity gate', 'plugin root not resolvable (machine-scope)') and raw internals (ABCD_PLUGIN_ROOT/CLAUDE_PLUGIN_ROOT, the history-index path) without saying what any of it means for the user's work. An Iris (product thinker) or Nia (facilitator) cannot tell what just changed, which items need action, or why they'd care. Like iss-163, core emits result data with no user-facing explanation layer; each reported item needs a plain-language 'what this is / why it matters / what, if anything, you should do' framing at every front door. \ No newline at end of file +The ahoy install completion summary is written for abcd's implementers, not its personas: it leads with insider vocabulary ('managed repo', 'canonical marker blocks', 'the identity gate', 'plugin root not resolvable (machine-scope)') and raw internals (ABCD_PLUGIN_ROOT/CLAUDE_PLUGIN_ROOT, the history-index path) without saying what any of it means for the user's work. An Iris (product thinker) or Nia (facilitator) cannot tell what just changed, which items need action, or why they'd care. Like iss-163, core emits result data with no user-facing explanation layer; each reported item needs a plain-language 'what this is / why it matters / what, if anything, you should do' framing at every front door. + +## Grounds + +- pursued: every item an install reports reaches the person with core's what/why/action framing and no raw environment names; shown wrong if a reported write, declined category, outstanding step or optional step has no summary item, which the install summary tests catch From 14ec74e2e1494450635c7844d8121fabfc994917 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:11:30 +0100 Subject: [PATCH 032/107] fix(ahoy): delete the unreachable ahoy.Status renderer and audit for its kind ahoy.Status, the bare-command text summary, had no caller at all: not the CLI, not the plugin surface, not a test. The choice was to wire a front door or delete it, and it is deleted, on these grounds: - The bare form already has its front doors. `abcd ahoy` renders its own, fuller text summary (folder kind, plugin root, install mode, status line, vintage, staleness, citations, guard and banlist health) and the plugin page runs `abcd ahoy --json`. Status reproduced a subset of that render, so wiring it would add a second renderer for output already delivered. - No surface promises what only Status would deliver. The ahoy brief chapter records `status` as a plugin-page alias for the bare form and states that `abcd ahoy status` is refused as an unknown command, and the sub-verb table lists no `status`, so there is no chapter line to remove. - Wired-or-it-isn't-done favours deletion when nothing promises the output. TestEveryExportedAhoyFunctionHasAFrontDoor is the caller audit the record asked for, scoped to this package: every exported top-level function must be named by production code outside it, tests excluded; a declared cross-package test seam (named ...ForTest) is exempt. It failed on Status before the deletion. Refs: iss-33 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/apply.go | 31 ------- internal/core/ahoy/exported_reach_test.go | 104 ++++++++++++++++++++++ 2 files changed, 104 insertions(+), 31 deletions(-) create mode 100644 internal/core/ahoy/exported_reach_test.go diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index bdceda63c..37f211b9c 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -1554,37 +1554,6 @@ func auditGaps(cwd string, det DetectionResult) []Gap { return gaps } -// Status renders the bare-command human summary. Zero writes. -func Status(cwd string) (string, error) { - det, err := Detect(cwd) - if err != nil { - return "", err - } - var b strings.Builder - fmt.Fprintf(&b, "abcd ahoy — %s\n", det.FolderKind) - fmt.Fprintf(&b, "plugin root: %s\n", det.PluginRootStatus) - if det.RootSHA != "" { - fmt.Fprintf(&b, "root sha: %s\n", shortSHA(det.RootSHA)) - } - if mode, _ := det.Signals["install_mode"].(string); mode != "" { - fmt.Fprintf(&b, "install: %s\n", mode) - } - act := actionable(det.Gaps) - switch det.FolderKind { - case UnmanagedFolder: - b.WriteString("nothing to act on (not a git repo, no abcd markers)\n") - case UnmanagedRepo: - b.WriteString("unmanaged repo — run `abcd ahoy install` to adopt it\n") - default: - if len(act) == 0 { - b.WriteString("already up to date\n") - } else { - fmt.Fprintf(&b, "%d actionable gap(s) — run `abcd ahoy install`\n", len(act)) - } - } - return b.String(), nil -} - // --------------------------------------------------------------------------- // approval + gap helpers // --------------------------------------------------------------------------- diff --git a/internal/core/ahoy/exported_reach_test.go b/internal/core/ahoy/exported_reach_test.go new file mode 100644 index 000000000..4bbd1aa3d --- /dev/null +++ b/internal/core/ahoy/exported_reach_test.go @@ -0,0 +1,104 @@ +package ahoy + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// TestEveryExportedAhoyFunctionHasAFrontDoor is the caller audit iss-33 asked +// for, scoped to this package: an exported function exists to be reached from +// outside it, so one that no production code outside the package names is +// silent scaffolding — it compiles, it can rot, and nothing would notice. The +// loud way to stage a function is to leave it unexported until a front door +// calls it (loud-staging.md); an exported one nothing reaches is refused here. +// +// It reads the package's own non-test files for exported top-level functions, +// then the module's non-test Go files outside the package for a selector +// naming each (ahoy.Name). Tests do not count as callers: a function reached +// only by its own test is exactly the scaffolding the rule is about. +func TestEveryExportedAhoyFunctionHasAFrontDoor(t *testing.T) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatal(err) + } + exported := map[string]bool{} + for _, pkg := range pkgs { + for _, f := range pkg.Files { + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Recv != nil || !fn.Name.IsExported() { + continue + } + // A cross-package test seam says so in its name: it exists for + // front-door test packages that cannot reach an unexported seam, + // and production never calls it by construction. + if strings.HasSuffix(fn.Name.Name, "ForTest") { + continue + } + exported[fn.Name.Name] = false + } + } + } + if len(exported) == 0 { + t.Fatal("found no exported functions; the audit is reading the wrong directory") + } + + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("module root not found at %s: %v", root, err) + } + self, _ := filepath.Abs(".") + selector := regexp.MustCompile(`\bahoy\.([A-Z][A-Za-z0-9_]*)`) + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if p == self || name == ".git" || name == "node_modules" || (strings.HasPrefix(name, ".") && p != root) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + for _, m := range selector.FindAllStringSubmatch(string(data), -1) { + if _, ok := exported[m[1]]; ok { + exported[m[1]] = true + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + var unreached []string + for name, reached := range exported { + if !reached { + unreached = append(unreached, name) + } + } + sort.Strings(unreached) + if len(unreached) > 0 { + t.Fatalf("exported ahoy functions no production code outside the package calls — wire a front door or unexport/delete them: %v", unreached) + } +} From 92cad036a89cb724cf768d0d040804bedbd67dc4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:11:38 +0100 Subject: [PATCH 033/107] =?UTF-8?q?chore:=20resolve=20iss-33=20=E2=80=94?= =?UTF-8?q?=20the=20unreachable=20ahoy.Status=20is=20gone=20and=20audited?= =?UTF-8?q?=20for?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-33 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/plans/2026-08-15-plugin-user-safety.md | 2 +- .../issues/{open => resolved}/iss-33-ahoy-verb-hygiene.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-33-ahoy-verb-hygiene.md (85%) diff --git a/.abcd/development/plans/2026-08-15-plugin-user-safety.md b/.abcd/development/plans/2026-08-15-plugin-user-safety.md index 69a421825..e6965261a 100644 --- a/.abcd/development/plans/2026-08-15-plugin-user-safety.md +++ b/.abcd/development/plans/2026-08-15-plugin-user-safety.md @@ -98,7 +98,7 @@ once. Human-paired (the §4 gate is manual by design). No ordering constraints among them; each is small and autonomous-eligible unless its body says otherwise: -[iss-33](../../work/issues/open/iss-33-ahoy-verb-hygiene.md) (unvalidated +[iss-33](../../work/issues/resolved/iss-33-ahoy-verb-hygiene.md) (unvalidated interactive answers persisted), [iss-221](../../work/issues/open/iss-221-refounding-lineage-prompt-is-a-one-shot.md), [iss-222](../../work/issues/resolved/iss-222-install-dev-silent-noop-over-unowned-wrapper.md), diff --git a/.abcd/work/issues/open/iss-33-ahoy-verb-hygiene.md b/.abcd/work/issues/resolved/iss-33-ahoy-verb-hygiene.md similarity index 85% rename from .abcd/work/issues/open/iss-33-ahoy-verb-hygiene.md rename to .abcd/work/issues/resolved/iss-33-ahoy-verb-hygiene.md index 6fa9988b6..1d4f18258 100644 --- a/.abcd/work/issues/open/iss-33-ahoy-verb-hygiene.md +++ b/.abcd/work/issues/resolved/iss-33-ahoy-verb-hygiene.md @@ -7,6 +7,10 @@ category: "bug" source: "agent-finding" found_during: "2026-07-08 multi-agent review" found_at: "internal/core/ahoy/apply.go" +resolution: "Deleted ahoy.Status: the bare form's text summary is rendered by the abcd ahoy front door and the plugin page runs ahoy --json, and no surface promises Status's output; TestEveryExportedAhoyFunctionHasAFrontDoor now refuses an exported ahoy function no production code outside the package calls." +impact: internal +resolved_by: + commit: "14ec74e2" --- RE-SCOPED 2026-09-09 against the shipped tree. Every instance in the original @@ -57,3 +61,7 @@ coverage-plus-caller audit that distinguishes loud staging from silent scaffolding, per `.abcd/development/principles/loud-staging.md` — is still the right shape and is still unbuilt; `ahoy.Status` is exactly the instance it would catch. + +## Grounds + +- pursued: no exported ahoy renderer exists that no front door reaches; shown wrong if an exported ahoy function without an outside production caller passes the package's caller audit From f99a04f8a0cfd6783363dcf1af8ec1cf13652243 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:11:51 +0100 Subject: [PATCH 034/107] chore: capture the core-wide exported-reach audit as follow-up work Refs: iss-2609252211487887 Assisted-by: Claude:claude-opus-5-5 --- ...ch-caller-audit-iss-33-asked-for-exists-only.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md diff --git a/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md b/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md new file mode 100644 index 000000000..bce532733 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609252211487887" +slug: "the-exported-reach-caller-audit-iss-33-asked-for-exists-only" +severity: "minor" +category: "tech-debt" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/exported_reach_test.go" +--- + +The exported-reach caller audit iss-33 asked for exists only for internal/core/ahoy (TestEveryExportedAhoyFunctionHasAFrontDoor). A crude survey of the other internal/core packages (an exported top-level function with no 'pkg.Name' selector in non-test Go outside its package) lists about 140 names; many are reached only inside their own package, which is over-export rather than dead code, but some have no production caller anywhere, e.g. launch.Ship (grep for '.Ship(' outside tests finds none). Each hit needs sorting into dead scaffolding (delete or wire), in-package-only (unexport), or a declared test seam (name it ...ForTest), and the audit then generalised to every core package. From b53dd22d8588305a0f0ed483d9aaa7ffca4c734a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:12:25 +0100 Subject: [PATCH 035/107] test(ahoy): pin value prompts to stderr under --yes without a terminal The record reports an install --yes value prompt on stdout. It does not reproduce at a3cdf26e: the stdin prompter has written to the command's stderr since the first install front door (1894ab71), and the observation was most likely read off a run with the two streams merged. The test pins the behaviour for the text render, where stdout is the run's receipt: the unanswered visibility question is asked on stderr and never on stdout. Refs: iss-2609012039114508 Assisted-by: Claude:claude-opus-5-5 --- internal/surface/cli/ahoy_prompt_help_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/surface/cli/ahoy_prompt_help_test.go b/internal/surface/cli/ahoy_prompt_help_test.go index e0fe4f961..1702f2fae 100644 --- a/internal/surface/cli/ahoy_prompt_help_test.go +++ b/internal/surface/cli/ahoy_prompt_help_test.go @@ -124,3 +124,23 @@ func TestAhoyInstallTextLeadsWithThePlainSummary(t *testing.T) { t.Errorf("only %d summary items were printed:\n%s", shown, s) } } + +// TestAhoyInstallYesKeepsValuePromptsOffStdout pins iss-2609012039114508: under +// --yes with no terminal, a value question no flag answered is still asked, and +// it must be asked on the diagnostic stream. A scripted install reads stdout as +// the run's output, so a question there would read as a receipt line. +func TestAhoyInstallYesKeepsValuePromptsOffStdout(t *testing.T) { + hermeticEnv(t) + repo := gittest.NewRepo(t).Root() + t.Chdir(repo) + out, errOut, err := runCLIPipedStdinSplit(t, "", "ahoy", "install", "--yes", "--adopt") + if err != nil { + t.Fatalf("install exited non-zero: %v\n%s\n%s", err, out, errOut) + } + if strings.Contains(string(out), "visibility (private/public)") { + t.Errorf("a value prompt landed on stdout:\n%s", out) + } + if !strings.Contains(string(errOut), "visibility (private/public) []: <no answer>") { + t.Errorf("the value prompt was not asked on stderr:\n%s", errOut) + } +} From fafc437251cf527d8faad5a726d8b118fb4b02f1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:12:28 +0100 Subject: [PATCH 036/107] =?UTF-8?q?chore:=20resolve=20iss-2609012039114508?= =?UTF-8?q?=20=E2=80=94=20value=20prompts=20never=20reach=20stdout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609012039114508 Assisted-by: Claude:claude-opus-5-5 --- ...nstall-yes-emits-interactive-value-prompt-on-stdout.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md (63%) diff --git a/.abcd/work/issues/open/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md b/.abcd/work/issues/resolved/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md similarity index 63% rename from .abcd/work/issues/open/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md rename to .abcd/work/issues/resolved/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md index 559997719..7d00b4a3c 100644 --- a/.abcd/work/issues/open/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md +++ b/.abcd/work/issues/resolved/iss-2609012039114508-install-yes-emits-interactive-value-prompt-on-stdout.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/apply.go" +resolution: "Does not reproduce: the stdin prompter has written every question to stderr since 1894ab71, so an --yes run's unanswered value prompt never reaches stdout; the report was most likely read off merged streams. TestAhoyInstallYesKeepsValuePromptsOffStdout now pins it for the text render." +impact: internal +resolved_by: + commit: "b53dd22d" --- Observation from the lane LA assessment (GHSA-4q78 reproduction): `ahoy install --yes` with missing config values still emitted an interactive value prompt ("visibility (private/public) []:") on stdout, then proceeded as partial when stdin was empty. `--yes` approves categories, not values, so the partial outcome may be by design, but a prompt landing on stdout under a non-interactive flag with no TTY is worth a look: a scripted install reads it as output, and the prompt is the one line in the run that is not a receipt. Not fixed in this run. + +## Grounds + +- pursued: an install --yes without a terminal asks its value questions on stderr only; shown wrong if the pinned test sees a value prompt on stdout From f234ae28d414760eadb8338499dd8f27770351d4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:15:32 +0100 Subject: [PATCH 037/107] fix(ahoy): a failed install write says what was not done installDevShim returned bare on each of its three failure paths (removing the pinned entry it replaces, creating the directory, writing the shim), so a run that installed no dev entry reported partial or clean with no reason. Each now leaves a note naming what was not done and why. The same silence was in its siblings, fixed here too: the pinned install's removal of a dev shim it replaces (the twin in the other direction), and the starter settings file, the rules file, the setup-version stamp and the machine registration, each of which dropped a failed write without a word. Refs: iss-227 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/apply.go | 28 +++- internal/core/ahoy/dev_shim_failure_test.go | 156 ++++++++++++++++++++ 2 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 internal/core/ahoy/dev_shim_failure_test.go diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 37f211b9c..5f5670127 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -444,9 +444,11 @@ func (a *applyCtx) stepSkeleton() { return } cfg := map[string]any{"meta": map[string]any{"schema_version": 1}} - if err := writeConfig(a.cwd, cfg); err == nil { - a.note(writeSettings, configPath(a.cwd)) + if err := writeConfig(a.cwd, cfg); err != nil { + a.refuse("could not write the starter settings file .abcd/config.json: " + errText(err)) + return } + a.note(writeSettings, configPath(a.cwd)) } // stepConfigValues collects and persists the four config values. Returns nil on @@ -756,7 +758,9 @@ func (a *applyCtx) stepHistory() { "github": a.det.RepoIdentity.Github, "corpus": map[string]any{"transcripts": corpus}, } - if err := writeJSON(metaPath, meta); err == nil { + if err := writeJSON(metaPath, meta); err != nil { + a.refuse("could not register this repository on this machine (" + displayPath(metaPath) + "): " + errText(err)) + } else { a.note(writeSessionStore, metaPath) } } @@ -1138,6 +1142,8 @@ func (a *applyCtx) installDevShim(target string, kind binTargetKind) { } if kind == binTargetOwnedSymlink || kind == binTargetOwnedCopy { if err := os.Remove(target); err != nil { + a.refuse("could not replace the existing PATH entry " + displayPath(target) + " with the dev shim: " + errText(err) + + "; the pinned entry is left as it was") return } // The provenance record vouches for an entry that is gone; keeping it @@ -1148,6 +1154,7 @@ func (a *applyCtx) installDevShim(target string, kind binTargetKind) { a.echoChange("install_mode", "pinned", "dev") } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + a.refuse("could not create the install directory " + displayPath(filepath.Dir(target)) + " for the dev shim: " + errText(err)) return } content := renderDevShim(a.det.pluginRoot, pluginBinaryPath(a.det.pluginRoot)) @@ -1155,6 +1162,7 @@ func (a *applyCtx) installDevShim(target string, kind binTargetKind) { // store: a symlink pre-planted at the leaf is replaced, never written through, // and the executable bit is set via fchmod on the temp descriptor. if err := fsutil.WriteFileAtomic(target, []byte(content), 0o755); err != nil { + a.refuse("could not write the dev PATH entry " + displayPath(target) + ": " + errText(err) + "; no abcd was installed there") return } a.note(writeCommandEntry, target) @@ -1286,6 +1294,8 @@ func (a *applyCtx) installPinnedSymlink(target string, kind binTargetKind) { } if kind == binTargetDevShim { if err := os.Remove(target); err != nil { + a.refuse("could not replace the dev PATH entry " + displayPath(target) + " with the pinned one: " + errText(err) + + "; the dev entry is left as it was") return } a.echoChange("install_mode", "dev", "pinned") @@ -1373,9 +1383,11 @@ func (a *applyCtx) stepRules() { rules := map[string]any{"schema_version": 1, "disabled": false, "domains": map[string]any{}} // Contained through an os.Root opened at the repo: a committed `.abcd` ancestor // symlink must not land rules.json outside the working tree (GHSA-xrf8-4432-gw2f). - if err := writeRepoJSON(a.cwd, rulesRelPath, rules); err == nil { - a.note(writeRules, filepath.Join(a.cwd, ".abcd", "rules.json")) + if err := writeRepoJSON(a.cwd, rulesRelPath, rules); err != nil { + a.refuse("could not write .abcd/rules.json: " + errText(err)) + return } + a.note(writeRules, filepath.Join(a.cwd, ".abcd", "rules.json")) } // stepVersionStamp writes the meta setup block. @@ -1402,9 +1414,11 @@ func (a *applyCtx) stepVersionStamp() { meta["setup_date"] = time.Now().UTC().Format("2006-01-02") meta["project_name"] = a.det.RepoIdentity.Name cfgMap["meta"] = meta - if err := writeConfig(a.cwd, cfgMap); err == nil { - a.note(writeSettings, configPath(a.cwd)) + if err := writeConfig(a.cwd, cfgMap); err != nil { + a.refuse("could not record the setup version in .abcd/config.json: " + errText(err)) + return } + a.note(writeSettings, configPath(a.cwd)) } // Uninstall removes the marker block and the owned PATH entry (the spc-35 owned diff --git a/internal/core/ahoy/dev_shim_failure_test.go b/internal/core/ahoy/dev_shim_failure_test.go new file mode 100644 index 000000000..fe7b794d2 --- /dev/null +++ b/internal/core/ahoy/dev_shim_failure_test.go @@ -0,0 +1,156 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestInstallDevShimNotesEveryFailure is iss-227: each of the three writes the +// dev-shim install makes (removing the pinned entry it replaces, creating the +// directory, writing the shim) used to fail with a bare return, so a run that +// installed nothing reported no reason. Each failure must leave a note naming +// what was not done, and no write on the receipt. +func TestInstallDevShimNotesEveryFailure(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions, so the forced failures cannot occur") + } + _, pluginRoot := setupHermetic(t) + + readOnlyDir := func(t *testing.T) string { + t.Helper() + d := t.TempDir() + t.Cleanup(func() { _ = os.Chmod(d, 0o755) }) + return d + } + + cases := []struct { + name string + setup func(t *testing.T) (target string, kind binTargetKind) + want string + }{ + { + name: "remove", + setup: func(t *testing.T) (string, binTargetKind) { + d := readOnlyDir(t) + target := filepath.Join(d, "abcd") + if err := os.Symlink(pluginBinaryPath(pluginRoot), target); err != nil { + t.Fatal(err) + } + if err := os.Chmod(d, 0o555); err != nil { + t.Fatal(err) + } + return target, binTargetOwnedSymlink + }, + want: "could not replace the existing PATH entry", + }, + { + name: "mkdir", + setup: func(t *testing.T) (string, binTargetKind) { + f := filepath.Join(t.TempDir(), "a-file") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + return filepath.Join(f, "bin", "abcd"), binTargetAbsent + }, + want: "could not create the install directory", + }, + { + name: "write", + setup: func(t *testing.T) (string, binTargetKind) { + d := readOnlyDir(t) + if err := os.Chmod(d, 0o555); err != nil { + t.Fatal(err) + } + return filepath.Join(d, "abcd"), binTargetAbsent + }, + want: "could not write the dev PATH entry", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + target, kind := tc.setup(t) + a := &applyCtx{cwd: t.TempDir(), det: DetectionResult{pluginRoot: pluginRoot}} + a.installDevShim(target, kind) + if len(a.writes) != 0 { + t.Errorf("a failed shim install reported writes %v", a.writes) + } + joined := strings.Join(a.notes, "\n") + if !strings.Contains(joined, tc.want) { + t.Errorf("no note saying %q; notes = %q", tc.want, a.notes) + } + }) + } +} + +// TestInstallPinnedSymlinkNotesAFailedShimRemoval is the twin of the dev-shim +// case in the opposite direction: switching a dev shim back to the pinned +// entry removes the shim first, and that removal returned bare on failure. +func TestInstallPinnedSymlinkNotesAFailedShimRemoval(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions, so the forced failure cannot occur") + } + _, pluginRoot := setupHermetic(t) + d := t.TempDir() + t.Cleanup(func() { _ = os.Chmod(d, 0o755) }) + target := filepath.Join(d, "abcd") + if err := os.WriteFile(target, []byte(renderDevShim(pluginRoot, pluginBinaryPath(pluginRoot))), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(d, 0o555); err != nil { + t.Fatal(err) + } + a := &applyCtx{cwd: t.TempDir(), det: DetectionResult{pluginRoot: pluginRoot}} + a.installPinnedSymlink(target, binTargetDevShim) + if len(a.writes) != 0 { + t.Errorf("a failed switch reported writes %v", a.writes) + } + if !strings.Contains(strings.Join(a.notes, "\n"), "could not replace the dev PATH entry") { + t.Errorf("no note for the failed shim removal; notes = %q", a.notes) + } +} + +// TestRepoWriteStepsNoteAFailedWrite covers the same silence in the steps that +// write the repository's own files: the starter config, the rules file and the +// setup stamp each dropped a failed write without a word. +func TestRepoWriteStepsNoteAFailedWrite(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions, so the forced failure cannot occur") + } + setupHermetic(t) + repo := t.TempDir() + idMustGit(t, repo, "init") + abcd := filepath.Join(repo, ".abcd") + if err := os.MkdirAll(abcd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(abcd, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(abcd, 0o755) }) + + for _, tc := range []struct { + name, gap, want string + step func(a *applyCtx) + }{ + {"skeleton", "skeleton.config_missing", "could not write the starter settings", (*applyCtx).stepSkeleton}, + {"rules", "rules.missing", "could not write .abcd/rules.json", (*applyCtx).stepRules}, + {"stamp", "install_meta.missing", "could not record the setup version", (*applyCtx).stepVersionStamp}, + } { + t.Run(tc.name, func(t *testing.T) { + a := &applyCtx{ + cwd: repo, + approved: map[GapCategory]bool{SafeAutocreate: true}, + gapPresent: map[string]bool{tc.gap: true}, + } + tc.step(a) + if len(a.writes) != 0 { + t.Errorf("a failed write reported writes %v", a.writes) + } + if !strings.Contains(strings.Join(a.notes, "\n"), tc.want) { + t.Errorf("no note saying %q; notes = %q", tc.want, a.notes) + } + }) + } +} From 5104c2284a8a261e830f0db63d7cf397ef69bfa8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:15:36 +0100 Subject: [PATCH 038/107] =?UTF-8?q?chore:=20resolve=20iss-227=20=E2=80=94?= =?UTF-8?q?=20failed=20install=20writes=20are=20noted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-227 Assisted-by: Claude:claude-opus-5-5 --- .../plans/2026-08-15-plugin-user-safety.md | 2 +- ...ly-swallows-failures-the-os-remove-mkdi.md | 12 ----------- ...ly-swallows-failures-the-os-remove-mkdi.md | 20 +++++++++++++++++++ 3 files changed, 21 insertions(+), 13 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md create mode 100644 .abcd/work/issues/resolved/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md diff --git a/.abcd/development/plans/2026-08-15-plugin-user-safety.md b/.abcd/development/plans/2026-08-15-plugin-user-safety.md index e6965261a..526ba3506 100644 --- a/.abcd/development/plans/2026-08-15-plugin-user-safety.md +++ b/.abcd/development/plans/2026-08-15-plugin-user-safety.md @@ -102,7 +102,7 @@ unless its body says otherwise: interactive answers persisted), [iss-221](../../work/issues/open/iss-221-refounding-lineage-prompt-is-a-one-shot.md), [iss-222](../../work/issues/resolved/iss-222-install-dev-silent-noop-over-unowned-wrapper.md), -[iss-227](../../work/issues/open/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md), +[iss-227](../../work/issues/resolved/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md), [iss-228](../../work/issues/open/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md). ## Structural tier — designed, deliberately not next diff --git a/.abcd/work/issues/open/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md b/.abcd/work/issues/open/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md deleted file mode 100644 index 22d6677ac..000000000 --- a/.abcd/work/issues/open/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-227" -slug: "installdevshim-silently-swallows-failures-the-os-remove-mkdi" -severity: "minor" -category: "observation" -source: "agent-observation" -found_during: "ahoy install dogfood" -found_at: "internal/core/ahoy/apply.go" ---- - -installDevShim silently swallows failures: the os.Remove, MkdirAll, and WriteFileAtomic error paths are bare returns (internal/core/ahoy/apply.go, installDevShim), so a failed shim write yields status=partial or clean with no note explaining what was not done or why; only the success path calls a.note \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md b/.abcd/work/issues/resolved/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md new file mode 100644 index 000000000..8c7eda45d --- /dev/null +++ b/.abcd/work/issues/resolved/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-227" +slug: "installdevshim-silently-swallows-failures-the-os-remove-mkdi" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "ahoy install dogfood" +found_at: "internal/core/ahoy/apply.go" +resolution: "installDevShim's remove, mkdir and write failures each leave a note naming what was not done; the same silence in the pinned entry's dev-shim removal, the starter settings, rules, setup stamp and machine registration writes is fixed alongside." +impact: fix +resolved_by: + commit: "f234ae28" +--- + +installDevShim silently swallows failures: the os.Remove, MkdirAll, and WriteFileAtomic error paths are bare returns (internal/core/ahoy/apply.go, installDevShim), so a failed shim write yields status=partial or clean with no note explaining what was not done or why; only the success path calls a.note + +## Grounds + +- pursued: every failed install write reaches the result as a note; shown wrong if a forced failure in any of these steps leaves the notes empty, which the dev_shim_failure tests force From 85749fd71e0df1c756a81675f5ef96adfafc30ad Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:17:00 +0100 Subject: [PATCH 039/107] fix(ahoy): the pinned release tag is read only from a well-shaped data dir readPinnedTag read CLAUDE_PLUGIN_DATA/cache/binary-meta without the dataDirHazard shape check every other reader of that directory applies, so a relative, in-repository or world-writable data directory could supply the release tag the vintage line reports and staleBinaryRefusal trusts. It now takes the working directory and refuses such a directory's record. The sibling reader in the front door, readSkewMeta behind the session-start skew notice, read the same record the same unchecked way; it now asks core through ahoy.PluginDataDirHazard, a new exported wrapper over the one check, so the surface and core cannot disagree about which directories are believed. Refs: iss-2609020630242279 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/data_dir.go | 7 +++ internal/core/ahoy/pinned_tag_hazard_test.go | 62 ++++++++++++++++++++ internal/core/ahoy/vintage.go | 10 +++- internal/core/ahoy/vintage_test.go | 6 +- internal/surface/cli/skew.go | 8 +++ internal/surface/cli/skew_test.go | 25 ++++++++ 6 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 internal/core/ahoy/pinned_tag_hazard_test.go diff --git a/internal/core/ahoy/data_dir.go b/internal/core/ahoy/data_dir.go index 7f2ce9bf2..aff1ac192 100644 --- a/internal/core/ahoy/data_dir.go +++ b/internal/core/ahoy/data_dir.go @@ -145,3 +145,10 @@ func dataDirHazard(dataDir, cwd string) string { } return "" } + +// PluginDataDirHazard is dataDirHazard for a front door that reads the harness +// data directory itself (the session-start skew notice): the reason dataDir +// cannot be trusted, or "" when it has the shape the harness always gives it. +// One check, so the surface and core cannot disagree about which directories +// are believed. +func PluginDataDirHazard(dataDir, cwd string) string { return dataDirHazard(dataDir, cwd) } diff --git a/internal/core/ahoy/pinned_tag_hazard_test.go b/internal/core/ahoy/pinned_tag_hazard_test.go new file mode 100644 index 000000000..9018f1e62 --- /dev/null +++ b/internal/core/ahoy/pinned_tag_hazard_test.go @@ -0,0 +1,62 @@ +package ahoy + +import ( + "os" + "path/filepath" + "testing" +) + +// TestReadPinnedTagRefusesAHazardousDataDir is iss-2609020630242279: the +// release tag the vintage line reports, and staleBinaryRefusal reads, came from +// CLAUDE_PLUGIN_DATA's cache record without the shape check every other reader +// of that directory applies. A world-writable, relative or in-repository data +// directory is one the harness never produces, so its record is not believed. +func TestReadPinnedTagRefusesAHazardousDataDir(t *testing.T) { + _, pluginRoot := setupHermetic(t) + repo := t.TempDir() + plant := func(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "cache"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cache", "binary-meta"), []byte("release_tag=v9.9.9\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + t.Run("control", func(t *testing.T) { + dir := t.TempDir() + plant(t, dir) + t.Setenv("CLAUDE_PLUGIN_DATA", dir) + if got := readPinnedTag(pluginRoot, repo); got != "v9.9.9" { + t.Fatalf("a well-shaped data dir's tag = %q, want v9.9.9", got) + } + }) + t.Run("world-writable", func(t *testing.T) { + dir := t.TempDir() + plant(t, dir) + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_PLUGIN_DATA", dir) + if got := readPinnedTag(pluginRoot, repo); got != "" { + t.Fatalf("a world-writable data dir supplied the tag %q", got) + } + }) + t.Run("inside-the-repo", func(t *testing.T) { + dir := filepath.Join(repo, "data") + plant(t, dir) + t.Setenv("CLAUDE_PLUGIN_DATA", dir) + if got := readPinnedTag(pluginRoot, repo); got != "" { + t.Fatalf("an in-repository data dir supplied the tag %q", got) + } + }) + t.Run("relative", func(t *testing.T) { + t.Chdir(repo) + plant(t, filepath.Join(repo, "rel")) + t.Setenv("CLAUDE_PLUGIN_DATA", "rel") + if got := readPinnedTag(pluginRoot, t.TempDir()); got != "" { + t.Fatalf("a relative data dir supplied the tag %q", got) + } + }) +} diff --git a/internal/core/ahoy/vintage.go b/internal/core/ahoy/vintage.go index 001e0f414..6b3fa34b8 100644 --- a/internal/core/ahoy/vintage.go +++ b/internal/core/ahoy/vintage.go @@ -59,7 +59,7 @@ func Vintage(cwd string) VintageStatus { mode := detectInstallMode(pluginRoot, pluginOK) pinTag := "" if pluginOK { - pinTag = readPinnedTag(pluginRoot) + pinTag = readPinnedTag(pluginRoot, abs) } return vintageFrom(currentVintage(), mode, abs, core.Version, pinTag) } @@ -251,11 +251,15 @@ func recordedSetupVersion(cwd string) string { // root's .data-dir stamp (pluginDataDir). The same precedence lives in // internal/surface/cli/skew.go's readSkewMeta; the two readers should be // consolidated if either record changes shape again. -func readPinnedTag(pluginRoot string) string { +func readPinnedTag(pluginRoot, cwd string) string { if tag := metaReleaseTag(filepath.Join(pluginRoot, ".binary-meta")); tag != "" { return tag } - if data := pluginDataDir(pluginRoot).dir; data != "" { + // The data directory passes the same shape check as every other reader of + // it (dataDirHazard): a relative, in-repository or world-writable one is a + // value the harness never produces, and its record would otherwise supply + // the tag staleBinaryRefusal trusts (iss-2609020630242279). + if data := pluginDataDir(pluginRoot).dir; data != "" && dataDirHazard(data, cwd) == "" { return metaReleaseTag(filepath.Join(data, "cache", "binary-meta")) } return "" diff --git a/internal/core/ahoy/vintage_test.go b/internal/core/ahoy/vintage_test.go index c4d3b01ab..c3b99e092 100644 --- a/internal/core/ahoy/vintage_test.go +++ b/internal/core/ahoy/vintage_test.go @@ -142,7 +142,7 @@ func TestReadPinnedTagPrefersRootThenCache(t *testing.T) { } t.Setenv("CLAUDE_PLUGIN_DATA", data) - if got := readPinnedTag(root); got != "v9.9.9" { + if got := readPinnedTag(root, root); got != "v9.9.9" { t.Errorf("a cache-provisioned root must read the pin from the cache meta; got %q, want v9.9.9", got) } @@ -150,7 +150,7 @@ func TestReadPinnedTagPrefersRootThenCache(t *testing.T) { []byte("release_tag=v9.9.8\nrelease_sha=unknown\n"), 0o644); err != nil { t.Fatal(err) } - if got := readPinnedTag(root); got != "v9.9.8" { + if got := readPinnedTag(root, root); got != "v9.9.8" { t.Errorf("a root-local record must win — it describes THIS root's binary; got %q, want v9.9.8", got) } @@ -158,7 +158,7 @@ func TestReadPinnedTagPrefersRootThenCache(t *testing.T) { if err := os.Remove(filepath.Join(root, ".binary-meta")); err != nil { t.Fatal(err) } - if got := readPinnedTag(root); got != "" { + if got := readPinnedTag(root, root); got != "" { t.Errorf("no record anywhere must read as no pin; got %q", got) } } diff --git a/internal/surface/cli/skew.go b/internal/surface/cli/skew.go index 5742dfddf..112718f9b 100644 --- a/internal/surface/cli/skew.go +++ b/internal/surface/cli/skew.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/ahoy" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/termsafe" ) @@ -84,7 +85,14 @@ func readSkewMeta(root string) map[string]string { if meta := readBinaryMeta(filepath.Join(root, binaryMetaFile)); meta != nil { return meta } + // The data directory is believed only in the shape the harness gives it, + // through core's one check: a relative, in-checkout or world-writable one + // could otherwise fabricate or suppress the notice (iss-2609020630242279). if data := os.Getenv("CLAUDE_PLUGIN_DATA"); data != "" { + cwd, err := os.Getwd() + if err != nil || ahoy.PluginDataDirHazard(data, cwd) != "" { + return nil + } return readBinaryMeta(filepath.Join(data, "cache", "binary-meta")) } return nil diff --git a/internal/surface/cli/skew_test.go b/internal/surface/cli/skew_test.go index 784984db1..3f31b3fab 100644 --- a/internal/surface/cli/skew_test.go +++ b/internal/surface/cli/skew_test.go @@ -219,3 +219,28 @@ func TestHookSessionStartSilentOnUnknownOrMatchedRelease(t *testing.T) { }) } } + +// TestReadSkewMetaRefusesAHazardousDataDir is the front-door twin of +// iss-2609020630242279: the skew notice read CLAUDE_PLUGIN_DATA's cache record +// without the shape check core applies to that directory, so a world-writable +// or in-checkout data dir could fabricate or suppress the notice. +func TestReadSkewMetaRefusesAHazardousDataDir(t *testing.T) { + root := t.TempDir() + data := t.TempDir() + if err := os.MkdirAll(filepath.Join(data, "cache"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, "cache", "binary-meta"), []byte("release_sha="+strings.Repeat("a", 40)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_PLUGIN_DATA", data) + if readSkewMeta(root) == nil { + t.Fatal("control: a well-shaped data dir's record was not read") + } + if err := os.Chmod(data, 0o777); err != nil { + t.Fatal(err) + } + if meta := readSkewMeta(root); meta != nil { + t.Fatalf("a world-writable data dir supplied the record %v", meta) + } +} From 35157ecef7ca6a1b7f0e51df69c90cc82d268b37 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:17:05 +0100 Subject: [PATCH 040/107] =?UTF-8?q?chore:=20resolve=20iss-2609020630242279?= =?UTF-8?q?=20=E2=80=94=20pinned=20tag=20reader=20checks=20the=20data=20di?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609020630242279 Assisted-by: Claude:claude-opus-5-5 --- ...dtag-in-internal-core-ahoy-vintage-go-still-reads-c.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md (68%) diff --git a/.abcd/work/issues/open/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md b/.abcd/work/issues/resolved/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md similarity index 68% rename from .abcd/work/issues/open/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md rename to .abcd/work/issues/resolved/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md index 84cfe79b3..b86f9ee4e 100644 --- a/.abcd/work/issues/open/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md +++ b/.abcd/work/issues/resolved/iss-2609020630242279-readpinnedtag-in-internal-core-ahoy-vintage-go-still-reads-c.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/vintage.go" +resolution: "readPinnedTag gates the data directory through dataDirHazard, and the skew notice's readSkewMeta sibling does the same through ahoy.PluginDataDirHazard." +impact: fix +resolved_by: + commit: "85749fd7" --- readPinnedTag in internal/core/ahoy/vintage.go still reads CLAUDE_PLUGIN_DATA/cache/binary-meta without dataDirHazard, the one reader of that variable the owned-copy hardening did not route through the check. An environment-chosen relative, in-repo or world-writable data directory therefore supplies release_tag to currentVintage, which feeds staleBinaryRefusal, so a wrong vintage claim can suppress the stale-binary refusal that gates install logic. No write is demonstrated; the consequence is a wrong vintage claim. The fix is the same dataDirHazard gate at this reader, or a documented statement that the vintage line is display-only if that is decided. + +## Grounds + +- pursued: no release tag or skew record is read from a relative, in-repository or world-writable data directory; shown wrong if TestReadPinnedTagRefusesAHazardousDataDir or TestReadSkewMetaRefusesAHazardousDataDir reads a record from one From ea96cfc0ff514765f1a2781518468d8abed7859f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:19:03 +0100 Subject: [PATCH 041/107] fix(ahoy): a dev build on either side is no version transition versionTransitionFrom guarded a dev running binary but not a dev recorded setup_version, so a repository stamped by a local dev build reported a version transition against every release binary, permanently. detectVersion had the same asymmetry in both directions and raised version.upgrade, a required gap, for it; its remedy was a flap: a release install re-stamped the tracked config and the next dev install stamped dev back. Both now treat a dev (or unrecorded) version on either side as no transition, through one predicate. Refs: iss-2608241115259170 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/detect.go | 5 +++- internal/core/ahoy/transition_test.go | 39 +++++++++++++++++++++++++++ internal/core/ahoy/vintage.go | 9 ++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index c4610d7d5..86b84872e 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -779,7 +779,10 @@ func detectVersion(cwd string) []Gap { FixHint: "ahoy install stamps the meta block.", Required: true, Resolvable: true, }} } - if current := pluginVersion(); current != "" && setupVersion != current { + // Neither side a dev build (iss-2608241115259170): the gap is required, and + // against a dev stamp or a dev binary it could never settle — every + // release install would re-stamp and every dev install undo it. + if current := pluginVersion(); !isDevOrUnknown(current) && !isDevOrUnknown(setupVersion) && setupVersion != current { return []Gap{{ ID: "version.upgrade", Category: SafeAutocreate, Scope: "repo", Title: "plugin upgrade " + setupVersion + " -> " + current, diff --git a/internal/core/ahoy/transition_test.go b/internal/core/ahoy/transition_test.go index c59fc6e0c..e9a5474e9 100644 --- a/internal/core/ahoy/transition_test.go +++ b/internal/core/ahoy/transition_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/intentdriven/abcd/internal/core" ) func TestVersionTransitionFrom(t *testing.T) { @@ -18,6 +20,9 @@ func TestVersionTransitionFrom(t *testing.T) { {"same version is no transition", "v1.2.0", "v1.2.0", false}, {"a dev running version never reports a transition", "v1.2.0", "dev", false}, {"an unrecorded version never reports a transition", "", "v1.3.0", false}, + // iss-2608241115259170: a setup stamped by a dev build can never + // reconcile against a release, so it is no transition either. + {"a dev recorded version never reports a transition", "dev", "v1.3.0", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -45,3 +50,37 @@ func TestRecordedSetupVersionReadsConfig(t *testing.T) { t.Fatalf("recordedSetupVersion with no config = %q, want empty", got) } } + +// TestDetectVersionIgnoresADevSide is the detection half of +// iss-2608241115259170: version.upgrade is a required gap, and with either side +// a dev build it could never settle. A dev-stamped repo run by a release binary +// asked for a re-stamp that the next dev install undid, and the reverse flapped +// the tracked config back; neither is an upgrade. +func TestDetectVersionIgnoresADevSide(t *testing.T) { + prev := core.Version + t.Cleanup(func() { core.Version = prev }) + for _, c := range []struct { + name, recorded, running string + wantGap bool + }{ + {"release to release is an upgrade", "v1.2.0", "v1.3.0", true}, + {"a dev-stamped repo under a release binary", "dev", "v1.3.0", false}, + {"a release-stamped repo under a dev binary", "v1.2.0", "dev", false}, + } { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".abcd"), 0o755); err != nil { + t.Fatal(err) + } + cfg := `{"meta":{"setup_version":"` + c.recorded + `","setup_date":"2026-09-01","schema_version":1}}` + "\n" + if err := os.WriteFile(filepath.Join(dir, ".abcd", "config.json"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + core.Version = c.running + got := hasGap(detectVersion(dir), "version.upgrade") + if got != c.wantGap { + t.Fatalf("version.upgrade raised = %v, want %v", got, c.wantGap) + } + }) + } +} diff --git a/internal/core/ahoy/vintage.go b/internal/core/ahoy/vintage.go index 6b3fa34b8..090038f9c 100644 --- a/internal/core/ahoy/vintage.go +++ b/internal/core/ahoy/vintage.go @@ -224,12 +224,19 @@ func VersionTransition(cwd string) (recorded, running string, changed bool) { // versionTransitionFrom is the pure comparison, split so the change/no-change // branches are testable without a fixture config or a re-stamped core.Version. func versionTransitionFrom(recorded, running string) (from, to string, changed bool) { - if running == "" || running == "dev" || recorded == "" { + // A dev build on either side is no transition: a dev stamp can never + // reconcile against a release, and re-stamping it would flap the tracked + // config between dev and release installs (iss-2608241115259170). + if isDevOrUnknown(running) || isDevOrUnknown(recorded) { return recorded, running, false } return recorded, running, recorded != running } +// isDevOrUnknown reports a version that cannot take part in a comparison: none +// recorded, or a local dev build's. +func isDevOrUnknown(v string) bool { return v == "" || v == "dev" } + // recordedSetupVersion reads meta.setup_version from the repo config, or "" when // it is absent or unreadable. func recordedSetupVersion(cwd string) string { From 20e093920f98d3e155b62c0e7b9d8c5895e60078 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:19:06 +0100 Subject: [PATCH 042/107] =?UTF-8?q?chore:=20resolve=20iss-2608241115259170?= =?UTF-8?q?=20=E2=80=94=20dev=20versions=20are=20no=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608241115259170 Assisted-by: Claude:claude-opus-5-5 --- ...8241115259170-dev-setup-version-never-reconciles.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-2608241115259170-dev-setup-version-never-reconciles.md (65%) diff --git a/.abcd/work/issues/open/iss-2608241115259170-dev-setup-version-never-reconciles.md b/.abcd/work/issues/resolved/iss-2608241115259170-dev-setup-version-never-reconciles.md similarity index 65% rename from .abcd/work/issues/open/iss-2608241115259170-dev-setup-version-never-reconciles.md rename to .abcd/work/issues/resolved/iss-2608241115259170-dev-setup-version-never-reconciles.md index 550c0d141..2d21ec9be 100644 --- a/.abcd/work/issues/open/iss-2608241115259170-dev-setup-version-never-reconciles.md +++ b/.abcd/work/issues/resolved/iss-2608241115259170-dev-setup-version-never-reconciles.md @@ -7,6 +7,14 @@ category: "bug" source: "user-observation" found_during: "sessionstart-hook-error-investigation" found_at: "internal/core/ahoy/vintage.go" +resolution: "versionTransitionFrom and detectVersion both treat a dev or unrecorded version on either side as no transition, so a dev-stamped repo neither reports a permanent transition nor raises a version.upgrade gap that flaps the tracked config." +impact: fix +resolved_by: + commit: "ea96cfc0" --- -A dev-recorded setup_version can never reconcile against a release binary. versionTransitionFrom (internal/core/ahoy/vintage.go:222) guards running == dev but not recorded == dev, so a repo whose .abcd/config.json meta.setup_version was stamped by a local dev build reports a version transition against every release binary, permanently. The recommended fix is a no-op or a flap in exactly the repo that ships the code: ahoy install stamps core.Version of whichever binary runs it (internal/core/ahoy/apply.go:1020 via store.go:21), so abcd ahoy install from a dev PATH entry re-stamps dev and leaves the notice standing, while the plugin binary's install stamps the release version into a tracked file that the next dev install rewrites back. detectVersion (internal/core/ahoy/detect.go:599) carries the same asymmetry and raises version.upgrade as a required gap on the same footing. \ No newline at end of file +A dev-recorded setup_version can never reconcile against a release binary. versionTransitionFrom (internal/core/ahoy/vintage.go:222) guards running == dev but not recorded == dev, so a repo whose .abcd/config.json meta.setup_version was stamped by a local dev build reports a version transition against every release binary, permanently. The recommended fix is a no-op or a flap in exactly the repo that ships the code: ahoy install stamps core.Version of whichever binary runs it (internal/core/ahoy/apply.go:1020 via store.go:21), so abcd ahoy install from a dev PATH entry re-stamps dev and leaves the notice standing, while the plugin binary's install stamps the release version into a tracked file that the next dev install rewrites back. detectVersion (internal/core/ahoy/detect.go:599) carries the same asymmetry and raises version.upgrade as a required gap on the same footing. + +## Grounds + +- pursued: a repo whose setup_version is dev reports no transition and no upgrade gap under a release binary, and vice versa; shown wrong if TestVersionTransitionFrom or TestDetectVersionIgnoresADevSide sees one From af0982be67495584533cf2d4caac7b8298301c73 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:25:35 +0100 Subject: [PATCH 043/107] refactor(gitutil): one Toplevel call holds git's toplevel answer to its shape Nine wrappers ran `git rev-parse --show-toplevel` themselves and only the site's checked the answer. gitutil.Toplevel now asks once and refuses any answer that is not a single absolute line naming a directory containing the one asked about (ErrToplevelShape); ToplevelShaped is the same check for the one caller that must ask in git's absolute path format. The capture ledger root, the rules root, the site's checkout root, gitutil.CheckoutRoot, ahoy's remote worktree root, the banlist root, the CLI's capture root and the record-lint and scaffold-sync roots all go through it; the banlist worktree probe keeps its own equality check and adds ToplevelShaped. TestShowToplevelIsAskedOnlyThroughToplevel keeps the rule in one place: it failed on eight direct callers before this change. Refs: iss-2608292038186663 Assisted-by: Claude:claude-opus-5-5 --- cmd/record-lint/main.go | 10 +-- cmd/scaffold-sync/main.go | 10 +-- internal/core/ahoy/remote.go | 7 +- internal/core/banlist/worktree.go | 2 +- internal/core/capture/roots.go | 21 ++---- internal/core/rules/root.go | 4 +- internal/core/site/outdir.go | 11 +-- internal/gitutil/repo.go | 46 +++++++++++- internal/gitutil/toplevel_test.go | 114 ++++++++++++++++++++++++++++++ internal/surface/cli/banlist.go | 2 +- internal/surface/cli/cli.go | 2 +- 11 files changed, 180 insertions(+), 49 deletions(-) create mode 100644 internal/gitutil/toplevel_test.go diff --git a/cmd/record-lint/main.go b/cmd/record-lint/main.go index b6474c271..1a487578c 100644 --- a/cmd/record-lint/main.go +++ b/cmd/record-lint/main.go @@ -8,7 +8,6 @@ import ( "flag" "fmt" "os" - "os/exec" "path/filepath" "strings" @@ -155,15 +154,10 @@ func (m *multiFlag) Set(v string) error { // lint the wrong repository. The os.Getwd fallback is the correct root under the // Makefile/CI contract, so scrubbing global config introduces no regression. func resolveRoot() string { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Env = gitutil.IsolatedEnv() - out, err := cmd.Output() - if err == nil { - if top := strings.TrimSpace(string(out)); top != "" { + if wd, err := os.Getwd(); err == nil { + if top, err := gitutil.Toplevel(wd); err == nil { return top } - } - if wd, err := os.Getwd(); err == nil { return wd } return "." diff --git a/cmd/scaffold-sync/main.go b/cmd/scaffold-sync/main.go index ba247bbc5..d344a4d6f 100644 --- a/cmd/scaffold-sync/main.go +++ b/cmd/scaffold-sync/main.go @@ -24,7 +24,6 @@ import ( "flag" "fmt" "os" - "os/exec" "path/filepath" "strings" @@ -70,15 +69,10 @@ func main() { // correct root under the Makefile/CI contract, so scrubbing introduces no // regression. func resolveRoot() string { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Env = gitutil.IsolatedEnv() - out, err := cmd.Output() - if err == nil { - if top := strings.TrimSpace(string(out)); top != "" { + if wd, err := os.Getwd(); err == nil { + if top, err := gitutil.Toplevel(wd); err == nil { return top } - } - if wd, err := os.Getwd(); err == nil { return wd } return "." diff --git a/internal/core/ahoy/remote.go b/internal/core/ahoy/remote.go index 32466ad86..a9ad13e84 100644 --- a/internal/core/ahoy/remote.go +++ b/internal/core/ahoy/remote.go @@ -16,6 +16,7 @@ import ( "time" "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" ) // RepoSettingsMirrorRelPath is the committed mirror of the repo-object settings @@ -148,14 +149,10 @@ func nativeScanningOptedOut(cwd string) (optedOut, answerable bool) { // and what keeps the os.Root the mirror is written through pointed at the tier the // mirror belongs in rather than at a stray `.abcd` two levels down. func worktreeRoot(cwd string) (string, bool) { - out, err := runGit(cwd, "rev-parse", "--show-toplevel") + top, err := gitutil.Toplevel(cwd) if err != nil { return "", false } - top := strings.TrimSpace(out) - if top == "" { - return "", false - } return top, true } diff --git a/internal/core/banlist/worktree.go b/internal/core/banlist/worktree.go index 529c10bc8..1a0f235a5 100644 --- a/internal/core/banlist/worktree.go +++ b/internal/core/banlist/worktree.go @@ -100,7 +100,7 @@ func PrimaryWorktreeRoot(repoRoot string) (string, bool) { } // The candidate's own answers, from the candidate's own directory. top, err := gitutil.Run(primary, "rev-parse", "--path-format=absolute", "--show-toplevel") - if err != nil || top != primary { + if err != nil || top != primary || !gitutil.ToplevelShaped(primary, top) { return "", false } common, err := gitutil.Run(primary, "rev-parse", "--path-format=absolute", "--git-common-dir") diff --git a/internal/core/capture/roots.go b/internal/core/capture/roots.go index 22702e68e..ad66a8245 100644 --- a/internal/core/capture/roots.go +++ b/internal/core/capture/roots.go @@ -8,7 +8,6 @@ import ( "github.com/intentdriven/abcd/internal/core/recordid" "io/fs" "os" - "os/exec" "path/filepath" "regexp" "strings" @@ -111,19 +110,13 @@ func LedgerRoot(cwd string) (string, error) { // discoverRepoRoot returns the git worktree root containing start, or "". func discoverRepoRoot(start string) string { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = start - // Isolate: `rev-parse --show-toplevel` honours an inherited GIT_WORK_TREE/GIT_DIR - // over cmd.Dir, so without scrubbing an inherited value redirects repo-root - // discovery at a DIFFERENT tree — and the derived issuesRoot then reads and - // writes the ledger under an attacker-chosen path. Repo discovery needs no - // global config, so full isolation is safe. - cmd.Env = gitutil.IsolatedEnv() - out, err := cmd.Output() - if err == nil { - if root := strings.TrimSpace(string(out)); root != "" { - return root - } + // gitutil.Toplevel runs git isolated: `rev-parse --show-toplevel` honours an + // inherited GIT_WORK_TREE/GIT_DIR over the directory it is run in, so without + // scrubbing an inherited value redirects repo-root discovery at a DIFFERENT + // tree — and the derived issuesRoot then reads and writes the ledger under an + // attacker-chosen path. It also refuses an answer of the wrong shape. + if root, err := gitutil.Toplevel(start); err == nil { + return root } dir := start for { diff --git a/internal/core/rules/root.go b/internal/core/rules/root.go index 78f80d857..734023f8d 100644 --- a/internal/core/rules/root.go +++ b/internal/core/rules/root.go @@ -122,8 +122,8 @@ func Resolve(cwd string) Resolution { if err != nil { dir = filepath.Clean(cwd) } - top, err := gitutil.Run(cwd, "rev-parse", "--show-toplevel") - if err != nil || top == "" { + top, err := gitutil.Toplevel(cwd) + if err != nil { // The marker walk runs on the symlink-resolved path, so the bound it // returns is already on the same chain the walk below climbs. marker := gitutil.RepoShapedRoot(dir) diff --git a/internal/core/site/outdir.go b/internal/core/site/outdir.go index bd840fb76..6144f1f95 100644 --- a/internal/core/site/outdir.go +++ b/internal/core/site/outdir.go @@ -184,17 +184,12 @@ func resolveOutDir(repoRoot, outDir string) (string, error) { // refuseTrackedOutDir takes, because the boundary it would silently fall back // to is the one the rule exists to widen past. func checkoutRoot(repoRoot string) (string, error) { - top, err := gitutil.Run(repoRoot, "rev-parse", "--show-toplevel") - if err == nil && filepath.IsAbs(top) && !strings.ContainsRune(top, '\n') && - fsutil.PathWithin(fsutil.RealExistingPath(repoRoot), fsutil.RealExistingPath(top), fsutil.CaseFoldingFS()) { + top, err := gitutil.Toplevel(repoRoot) + if err == nil { return top, nil } if gitutil.RepoShaped(repoRoot) { - reason := "an answer that is not one absolute path containing this directory" - if err != nil { - reason = err.Error() - } - return "", fmt.Errorf("site: %s sits inside a checkout but git cannot name its root (%s); the symlink rule is keyed on that root, so refusing", repoRoot, reason) + return "", fmt.Errorf("site: %s sits inside a checkout but git cannot name its root (%s); the symlink rule is keyed on that root, so refusing", repoRoot, err.Error()) } return repoRoot, nil } diff --git a/internal/gitutil/repo.go b/internal/gitutil/repo.go index 87ca0c423..6467bcc6b 100644 --- a/internal/gitutil/repo.go +++ b/internal/gitutil/repo.go @@ -8,6 +8,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/intentdriven/abcd/internal/fsutil" ) // isolatedGit builds a git command under root with global and system config @@ -308,7 +310,7 @@ var ErrNoCheckoutRoot = errors.New("no checkout root") // which is the same lost trail this resolution exists to prevent. Every // store this resolves for is per-repository by definition. func CheckoutRoot(cwd, store string) (string, error) { - if top, err := Run(cwd, "rev-parse", "--show-toplevel"); err == nil && top != "" { + if top, err := Toplevel(cwd); err == nil { return top, nil } // Neither message carries the working directory: an error envelope never @@ -439,3 +441,45 @@ func runBounded(root string, maxBytes int, args ...string) (string, bool, error) // lose one. return strings.TrimRight(string(w.buf), " \t\r\n"), w.overflowed, nil } + +// ErrToplevelShape is Toplevel's refusal of an answer git would never give. +var ErrToplevelShape = errors.New("git's toplevel answer is not one absolute path containing the directory asked about") + +// Toplevel asks git for the working-tree root that contains dir, and holds the +// answer to the one shape git ever gives: a single absolute line naming a +// directory at or above dir. Anything else (relative, several lines, empty, a +// directory elsewhere) is refused with ErrToplevelShape rather than handed on +// as a root, because every caller bounds work at the answer: it is where a +// ledger, a rules file or a site is read and written. It is the one call every +// `rev-parse --show-toplevel` in the module goes through, so the rule is kept +// once (iss-2608292038186663). +// +// A git failure (not a repository, git absent, an ownership refusal under the +// isolated environment) is returned as git's own error; a caller that must +// tell "no repository" from "a repository git will not answer for" follows up +// with RepoShapedRoot. +func Toplevel(dir string) (string, error) { + top, err := Run(dir, "rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + if !ToplevelShaped(dir, top) { + return "", ErrToplevelShape + } + return top, nil +} + +// ToplevelShaped reports whether top has the shape of git's toplevel answer +// for dir: one absolute line naming a directory that contains dir. It is +// Toplevel's check, for the callers that must run git themselves (a command +// that pins the git binary it runs, or asks for the path in another format). +func ToplevelShaped(dir, top string) bool { + if top == "" || !filepath.IsAbs(top) || strings.ContainsAny(top, "\n\r") { + return false + } + abs, err := filepath.Abs(dir) + if err != nil { + return false + } + return fsutil.PathWithin(fsutil.RealExistingPath(abs), fsutil.RealExistingPath(top), fsutil.CaseFoldingFS()) +} diff --git a/internal/gitutil/toplevel_test.go b/internal/gitutil/toplevel_test.go new file mode 100644 index 000000000..f08d6121a --- /dev/null +++ b/internal/gitutil/toplevel_test.go @@ -0,0 +1,114 @@ +package gitutil_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gitutil" +) + +// fakeGitAnswering plants a git on PATH whose every answer is out, so the shape +// check on git's toplevel answer can be driven with answers real git never gives. +func fakeGitAnswering(t *testing.T, out string) { + t.Helper() + bin := t.TempDir() + script := "#!/bin/sh\nprintf '%s' '" + out + "'\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) +} + +// TestToplevelNamesTheRootOfARealCheckout is the ordinary answer: from a +// subdirectory, the working-tree root. +func TestToplevelNamesTheRootOfARealCheckout(t *testing.T) { + repo := t.TempDir() + if out, err := runGit(t, repo, "init", "-q"); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + sub := filepath.Join(repo, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + top, err := gitutil.Toplevel(sub) + if err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(repo) + got, _ := filepath.EvalSymlinks(top) + if got != want { + t.Fatalf("Toplevel = %q, want %q", top, repo) + } + if _, err := gitutil.Toplevel(t.TempDir()); err == nil { + t.Fatal("a directory outside any checkout was given a toplevel") + } +} + +// TestToplevelRefusesAnAnswerOfTheWrongShape is iss-2608292038186663: git's +// toplevel is always one absolute line naming a directory that contains the +// one asked about, and any other answer (relative, several lines, a directory +// elsewhere) is refused rather than handed on as a root. +func TestToplevelRefusesAnAnswerOfTheWrongShape(t *testing.T) { + dir := t.TempDir() + elsewhere := t.TempDir() + for name, answer := range map[string]string{ + "relative": "some/where", + "two lines": dir + "\n" + dir, + "elsewhere": elsewhere, + "empty": "", + } { + t.Run(name, func(t *testing.T) { + fakeGitAnswering(t, answer) + if top, err := gitutil.Toplevel(dir); err == nil { + t.Fatalf("the answer %q was accepted as the toplevel %q", answer, top) + } + }) + } +} + +// TestShowToplevelIsAskedOnlyThroughToplevel keeps the rule in one place: no +// production code outside this package runs `rev-parse --show-toplevel` itself, +// so no caller can skip the shape check. The one exception is the banlist +// worktree probe, which asks in git's absolute path format, requires the answer +// to equal the directory it asked from, and checks it with ToplevelShaped. +func TestShowToplevelIsAskedOnlyThroughToplevel(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + allowed := map[string]bool{ + filepath.Join("internal", "gitutil", "repo.go"): true, + filepath.Join("internal", "core", "banlist", "worktree.go"): true, + } + err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if n := d.Name(); p != root && (strings.HasPrefix(n, ".") || n == "node_modules") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + rel, _ := filepath.Rel(root, p) + if allowed[rel] { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + if strings.Contains(string(data), `"--show-toplevel"`) { + t.Errorf("%s runs rev-parse --show-toplevel itself; call gitutil.Toplevel", rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/surface/cli/banlist.go b/internal/surface/cli/banlist.go index 4e5b6ecf0..717a2c06b 100644 --- a/internal/surface/cli/banlist.go +++ b/internal/surface/cli/banlist.go @@ -518,7 +518,7 @@ func banlistRoot(w io.Writer) (string, error) { if err != nil { return "", err } - if top, err := gitutil.Run(cwd, "rev-parse", "--show-toplevel"); err == nil && top != "" { + if top, err := gitutil.Toplevel(cwd); err == nil { return top, nil } return rulesRoot(cwd, w), nil diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 809ec0f58..a1e180dd5 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4711,7 +4711,7 @@ func readSourceCapped(cmd *cobra.Command, spec string, limit int64) ([]byte, err // cwd when git cannot answer (not a repo, git absent) — the scanner then behaves // exactly as before, so the fallback never regresses a non-git use. func captureRoot(cwd string) string { - if top, err := gitutil.Run(cwd, "rev-parse", "--show-toplevel"); err == nil && top != "" { + if top, err := gitutil.Toplevel(cwd); err == nil { return top } return cwd From 5948c3cebb269f358fd99eb4389507c84a6cb95c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:25:38 +0100 Subject: [PATCH 044/107] =?UTF-8?q?chore:=20resolve=20iss-2608292038186663?= =?UTF-8?q?=20=E2=80=94=20one=20validated=20toplevel=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608292038186663 Assisted-by: Claude:claude-opus-5-5 --- ...038186663-nine-show-toplevel-wrappers-one-validated.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md (66%) diff --git a/.abcd/work/issues/open/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md b/.abcd/work/issues/resolved/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md similarity index 66% rename from .abcd/work/issues/open/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md rename to .abcd/work/issues/resolved/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md index a828cf8a5..53b2244a8 100644 --- a/.abcd/work/issues/open/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md +++ b/.abcd/work/issues/resolved/iss-2608292038186663-nine-show-toplevel-wrappers-one-validated.md @@ -8,6 +8,14 @@ source: "impl-review" found_during: "v0.6.9-security-pass" found_at: "internal/gitutil/repo.go" related_issues: ["iss-2608291924452604"] +resolution: "gitutil.Toplevel asks git once and refuses an answer that is not one absolute line containing the directory asked about; every show-toplevel caller routes through it, and a module scan test refuses a new direct caller." +impact: internal +resolved_by: + commit: "af0982be" --- v0.6.9 combined ruthless review: nine wrappers around 'git rev-parse --show-toplevel' exist and only one validates the answer. internal/core/site/outdir.go checkoutRoot holds the output to a single absolute line that contains the invoking directory; the other eight — internal/surface/cli/cli.go, internal/surface/cli/banlist.go, internal/core/capture/roots.go, internal/core/ahoy/remote.go, internal/core/banlist/worktree.go, cmd/record-lint and cmd/scaffold-sync, plus the site one — each re-derive the call. Proposal: one gitutil.Toplevel(dir) carrying the single-absolute-line-containing-the-invoking-directory rule, with every wrapper calling it. This REFINES iss-2608291924452604 (the ledger has no typed link, so the relation is stated here and in related_issues). + +## Grounds + +- pursued: no production code outside gitutil runs rev-parse --show-toplevel itself, and a malformed answer is refused; shown wrong if TestShowToplevelIsAskedOnlyThroughToplevel or TestToplevelRefusesAnAnswerOfTheWrongShape fails From fdd6624d35d01a67e5e74a64cfa4d966834fafa0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:26:27 +0100 Subject: [PATCH 045/107] fix(cli): captureRoot keeps a repository git will not answer for apart from none captureRoot fell back to the working directory on any git failure, so a repository git will not answer for (an ownership refusal, git absent, a corrupt .git) read as no repository, and a subdirectory became the root the scanner reads the per-repo redaction override from. The reading verbs are the ungated callers that could walk it. It now resolves three-state: git's toplevel, else the rules root's resolution, which admits the .git marker only for a plausible repository the caller owns or has declared trusted and otherwise stays at cwd, else cwd. In the middle state the rules resolution stops at the nearest .abcd between the working directory and the marker, not at the marker itself; that is where a .abcd-rooted override lives, and the same bound the rules loader and the banlist root already use. Refs: iss-2609020224230967 Assisted-by: Claude:claude-opus-5-5 --- .../cli/capture_root_three_state_test.go | 40 +++++++++++++++++++ internal/surface/cli/cli.go | 15 +++++-- 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 internal/surface/cli/capture_root_three_state_test.go diff --git a/internal/surface/cli/capture_root_three_state_test.go b/internal/surface/cli/capture_root_three_state_test.go new file mode 100644 index 000000000..9f8d05fa9 --- /dev/null +++ b/internal/surface/cli/capture_root_three_state_test.go @@ -0,0 +1,40 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestCaptureRootBoundsARepositoryGitWillNotAnswerFor is iss-2609020224230967: +// captureRoot fell back to the working directory on any git failure, so a +// repository git will not answer for (git absent from PATH here; an ownership +// refusal or a corrupt .git alike) was treated as no repository at all, and a +// subdirectory became the root the scanner reads its per-repo override from. +// It resolves three-state instead: git's toplevel, else the checkout the .git +// marker names (through the rules root's plausibility and ownership gates), +// else the working directory. +func TestCaptureRootBoundsARepositoryGitWillNotAnswerFor(t *testing.T) { + repo := gittest.NewRepo(t).Root() + sub := filepath.Join(repo, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(repo) + + if got, _ := filepath.EvalSymlinks(captureRoot(sub)); got != want { + t.Fatalf("control: captureRoot(sub) = %q, want the toplevel %q", got, want) + } + + t.Setenv("PATH", t.TempDir()) // git is absent: it answers for nothing + if got, _ := filepath.EvalSymlinks(captureRoot(sub)); got != want { + t.Fatalf("captureRoot(sub) with git unable to answer = %q, want the checkout %q", got, want) + } + + plain := t.TempDir() + if got := captureRoot(plain); got != plain { + t.Fatalf("outside any repository captureRoot = %q, want the working directory", got) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index a1e180dd5..f444cd4c1 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4707,14 +4707,21 @@ func readSourceCapped(cmd *cobra.Command, spec string, limit int64) ([]byte, err // capture honours the per-repo redaction override (the scanner resolves it at // <root>/.abcd/config/pii.json, without walking up). Without this, a capture run // from a subdirectory hands the subdirectory to scanner.New, which finds no -// override there and silently redacts with defaults only (B12). It falls back to -// cwd when git cannot answer (not a repo, git absent) — the scanner then behaves -// exactly as before, so the fallback never regresses a non-git use. +// override there and silently redacts with defaults only (B12). +// +// Three states, not two (iss-2609020224230967): git's toplevel when git +// answers; else, for a repository git will not answer for (an ownership +// refusal under the isolated env, git absent from PATH, a corrupt .git), the +// checkout the .git marker names, through the rules root's resolution, which +// admits a marker only when it is a plausible repository the caller owns (or +// has declared trusted) and otherwise stays at cwd; else cwd, where there is +// no repository at all and the scanner behaves exactly as before. Collapsing +// the middle state onto cwd treated a real working tree as no repository. func captureRoot(cwd string) string { if top, err := gitutil.Toplevel(cwd); err == nil { return top } - return cwd + return rules.ResolveRoot(cwd) } // historyStore is the shared front-door step for every `history` verb: resolve From c6219a22fe5b1ccab5db3c594453550acc9db283 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:26:31 +0100 Subject: [PATCH 046/107] =?UTF-8?q?chore:=20resolve=20iss-2609020224230967?= =?UTF-8?q?=20=E2=80=94=20captureRoot=20is=20three-state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609020224230967 Assisted-by: Claude:claude-opus-5-5 --- ...ot-in-internal-surface-cli-cli-go-is-the-same-colla.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md (89%) diff --git a/.abcd/work/issues/open/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md b/.abcd/work/issues/resolved/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md similarity index 89% rename from .abcd/work/issues/open/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md rename to .abcd/work/issues/resolved/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md index 1a57a40fa..fc8a7d623 100644 --- a/.abcd/work/issues/open/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md +++ b/.abcd/work/issues/resolved/iss-2609020224230967-captureroot-in-internal-surface-cli-cli-go-is-the-same-colla.md @@ -9,6 +9,10 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/cli.go" +resolution: "captureRoot resolves three-state: git's toplevel, else the rules root's plausibility- and ownership-gated marker resolution, else cwd, so a repository git will not answer for is no longer collapsed onto the working directory." +impact: fix +resolved_by: + commit: "fdd6624d" --- captureRoot (`internal/surface/cli/cli.go:3794`) is the same collapse the rules @@ -60,3 +64,7 @@ should land before any new caller is added without a root-SHA gate in front of it, because the gate is what is holding this closed, not the function. AMENDED 2026-09-09. The adjacent front-door defect this record was read against — iss-2609090951291524, every capture verb taking the working directory as the repo root — is fixed, and captureRoot gained no new callers in the fixing change. The capture verbs resolve through capture.LedgerRoot, which asks git and refuses the two states captureRoot collapses onto cwd, deliberately rather than reusing this function while it still collapses them. So the condition this record sets is intact: the reading verbs at reading.go:56, :121 and :237 remain the one ungated caller set, and the fix wanted here — three-state through gitutil.RepoShapedRoot, like the two siblings that already do it — is unchanged and still wanted before the next ungated caller is added. + +## Grounds + +- pursued: from a subdirectory of a repository git cannot answer for, captureRoot names the checkout, not the subdirectory; shown wrong if TestCaptureRootBoundsARepositoryGitWillNotAnswerFor returns the subdirectory From 540bb0a277d042694703fd8ff942d1770f635ece Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:26:58 +0100 Subject: [PATCH 047/107] =?UTF-8?q?chore:=20resolve=20iss-228=20=E2=80=94?= =?UTF-8?q?=20stale=20plugin-root=20binary=20already=20warned=20and=20refu?= =?UTF-8?q?sed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-228 Assisted-by: Claude:claude-opus-5-5 --- .../plans/2026-08-15-plugin-user-safety.md | 2 +- ...ry-repo-root-abcd-bin-abcd-darwin-arm64.md | 12 ----------- ...ry-repo-root-abcd-bin-abcd-darwin-arm64.md | 20 +++++++++++++++++++ 3 files changed, 21 insertions(+), 13 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md create mode 100644 .abcd/work/issues/resolved/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md diff --git a/.abcd/development/plans/2026-08-15-plugin-user-safety.md b/.abcd/development/plans/2026-08-15-plugin-user-safety.md index 526ba3506..2d51e99c4 100644 --- a/.abcd/development/plans/2026-08-15-plugin-user-safety.md +++ b/.abcd/development/plans/2026-08-15-plugin-user-safety.md @@ -103,7 +103,7 @@ interactive answers persisted), [iss-221](../../work/issues/open/iss-221-refounding-lineage-prompt-is-a-one-shot.md), [iss-222](../../work/issues/resolved/iss-222-install-dev-silent-noop-over-unowned-wrapper.md), [iss-227](../../work/issues/resolved/iss-227-installdevshim-silently-swallows-failures-the-os-remove-mkdi.md), -[iss-228](../../work/issues/open/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md). +[iss-228](../../work/issues/resolved/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md). ## Structural tier — designed, deliberately not next diff --git a/.abcd/work/issues/open/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md b/.abcd/work/issues/open/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md deleted file mode 100644 index 80f9d03b4..000000000 --- a/.abcd/work/issues/open/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-228" -slug: "the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64" -severity: "minor" -category: "observation" -source: "agent-observation" -found_during: "ahoy install dogfood" -found_at: "internal/core/ahoy" ---- - -The plugin-root binary (repo-root abcd -> bin/abcd-darwin-arm64) sat a month stale after iss-171 merged, so the ahoy skill's first resolution rung reported pre-iss-171 gaps (wrong target, missing --bin-dir) with no staleness signal; the skill and detection have no guard that warns when the plugin-root binary predates the source tip in a source checkout \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md b/.abcd/work/issues/resolved/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md new file mode 100644 index 000000000..b4a851ec6 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-228-the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-228" +slug: "the-plugin-root-binary-repo-root-abcd-bin-abcd-darwin-arm64" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "ahoy install dogfood" +found_at: "internal/core/ahoy" +resolution: "Already fixed on main before this lane: 0ead2664 warns at session start when a dogfood plugin-root binary trails its source tip (naming the binary, its revision, the tip and make build), and e7557959 refuses ahoy install through a stale or unknown-vintage binary; the bare ahoy detection reports vintage and staleness." +impact: fix +resolved_by: + commit: "0ead2664" +--- + +The plugin-root binary (repo-root abcd -> bin/abcd-darwin-arm64) sat a month stale after iss-171 merged, so the ahoy skill's first resolution rung reported pre-iss-171 gaps (wrong target, missing --bin-dir) with no staleness signal; the skill and detection have no guard that warns when the plugin-root binary predates the source tip in a source checkout + +## Grounds + +- pursued: a stale plugin-root binary in a source checkout is named at session start and refused at install; shown wrong if staleness_test.go's stale case renders no notice or an install through a stale binary writes From b662711059213bea2f1b210ce3ae5f9262680e99 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:27:39 +0100 Subject: [PATCH 048/107] fix(update): the foreign refusal says what it examined `abcd version` prints "dev" for any locally built binary and `abcd update` calls a link to one foreign; both are right, and the words collided because the refusal said only "not something abcd owns". It now names where the entry leads, the four things it checked (not the dev shim, not a link into a plugin install, not a verifiable regular file, no provenance record), and that a "dev" version is a build label rather than the dev-shim install shape. The update page says the same beside the dev-shim shape. The classifier is unchanged, as the record asks; `abcd version` already prints the install mode beside the version. Refs: iss-2608230943260391 Assisted-by: Claude:claude-opus-5-5 --- commands/update.md | 4 +++- internal/core/update/update.go | 11 ++++++++++- internal/core/update/update_test.go | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/commands/update.md b/commands/update.md index 7c0864e5d..2a8d04ea0 100644 --- a/commands/update.md +++ b/commands/update.md @@ -49,7 +49,9 @@ error.** Every refusal is a named shape with a remedy in `refusal`: never touches a plugin root. Tell the user to take a plugin update in the host. - `dev-shim` — the PATH entry is the track-latest dev shim; `abcd ahoy - install` switches modes first. + install` switches modes first. This names the install shape, not the version + string: a binary that `abcd version` reports as `dev` is any locally built + one, and a link to such a binary is `foreign`, not `dev-shim`. - `owned-dangling` — a plugin update stranded the entry; `abcd ahoy install` repoints it. - `owned-superseded` — the entry is abcd's own pin into a plugin vintage the diff --git a/internal/core/update/update.go b/internal/core/update/update.go index b5a5a0f1b..30e4a599f 100644 --- a/internal/core/update/update.go +++ b/internal/core/update/update.go @@ -202,7 +202,16 @@ func Plan(t ahoy.UpdateTarget) *Refusal { Remedy: "run `abcd ahoy install` — it replaces the pin with the current release", } case ahoy.UpdateTargetForeign: - detail := "the entry at " + targetPath + " is not something abcd owns, and abcd never clobbers a binary it does not own" + // Say what was examined (iss-2608230943260391): "not owned" alone cannot + // tell a reader whether the objection is the link, where it leads, or + // the missing provenance record, and a reader who has just seen `abcd + // version` print "dev" expects the dev-shim shape instead. + detail := "the entry at " + targetPath + if resolvedPath != "" && resolvedPath != targetPath { + detail += " leads to " + resolvedPath + ", which" + } + detail += " is not something abcd owns: it is not abcd's dev shim, not a link into a plugin install, not a regular file abcd can verify, and no provenance record abcd wrote names it; abcd never clobbers a binary it does not own." + + " A version of \"dev\" from `abcd version` is a build label (any locally built binary carries it), not the dev-shim install shape" if t.LaterOwned != "" { detail += "; a working abcd install sits shadowed behind it at " + fsutil.RedactHome(t.LaterOwned) } diff --git a/internal/core/update/update_test.go b/internal/core/update/update_test.go index 16cfeb543..a3aa7d2a4 100644 --- a/internal/core/update/update_test.go +++ b/internal/core/update/update_test.go @@ -621,3 +621,21 @@ func TestPlanRefusesSupersededNamingAhoyInstall(t *testing.T) { t.Errorf("the foreign remedy must not be offered for abcd's own pin: %+v", r) } } + +// TestPlanForeignRefusalNamesWhatItExamined is iss-2608230943260391: `abcd +// version` says "dev" and `abcd update` calls the same entry foreign, both +// correctly, because they describe unrelated properties. The foreign refusal +// must say what it examined (the entry, where it leads, and that no provenance +// record names it) and that a dev version string is a build label, not the +// dev-shim install shape, so the two words stop colliding. +func TestPlanForeignRefusalNamesWhatItExamined(t *testing.T) { + r := Plan(ahoy.UpdateTarget{Path: "/x/abcd", ResolvedPath: "/src/bin/abcd-darwin-arm64", Kind: ahoy.UpdateTargetForeign}) + if r == nil { + t.Fatal("foreign target must refuse") + } + for _, want := range []string{"/src/bin/abcd-darwin-arm64", "provenance record", "build label"} { + if !strings.Contains(r.Detail, want) { + t.Errorf("the foreign refusal does not say %q: %q", want, r.Detail) + } + } +} From 047198c5e916d8e1241566cc3e003f0b6c22882d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:27:42 +0100 Subject: [PATCH 049/107] =?UTF-8?q?chore:=20resolve=20iss-2608230943260391?= =?UTF-8?q?=20=E2=80=94=20foreign=20refusal=20names=20what=20it=20examined?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608230943260391 Assisted-by: Claude:claude-opus-5-5 --- ...-reports-dev-and-abcd-update-calls-the-same-path.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md (80%) diff --git a/.abcd/work/issues/open/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md b/.abcd/work/issues/resolved/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md similarity index 80% rename from .abcd/work/issues/open/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md rename to .abcd/work/issues/resolved/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md index 0b96694ad..8f725a15e 100644 --- a/.abcd/work/issues/open/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md +++ b/.abcd/work/issues/resolved/iss-2608230943260391-abcd-version-reports-dev-and-abcd-update-calls-the-same-path.md @@ -7,6 +7,14 @@ category: "process" source: "user-observation" found_during: "abcd-update-invocation-2026-08-23" found_at: "internal/core/ahoy/update_target.go" +resolution: "The foreign update refusal names where the entry leads, what it examined and that a dev version is a build label, not the dev-shim shape; the update page says the same; abcd version already prints the install mode." +impact: fix +resolved_by: + commit: "b6627110" --- -abcd version reports 'dev' and abcd update calls the same PATH entry 'foreign', and both are correct, which is the problem. Verified 2026-08-23 against ~/.local/bin/abcd, a symlink into a source checkout's bin/abcd-darwin-arm64. abcd version prints 'abcd dev, vintage 5d864c42bad9, up to date'. abcd update on the same entry refuses with shape foreign and the detail that the entry is not something abcd owns. Neither is wrong. The words describe unrelated properties that happen to collide. Version's 'dev' is the build-time version string, internal/core/core.go var Version = "dev", overridden by ldflags at release, so any make-build or go-run binary reports it. Update's classification is about the PATH entry's provenance: internal/core/ahoy/update_target.go resolves the occupant, isDevShimFile in store.go recognises a dev shim only by a byte prefix on the file itself, and an entry that is neither a shim, nor an owned symlink, nor a regular file falls to UpdateTargetForeign. A symlink to a hand-built binary is therefore foreign by construction and correctly so. The legibility failure is that commands/update.md lists dev-shim as its own named refusal shape with its own remedy, abcd ahoy install, so a reader who has just seen 'abcd dev' has every reason to predict that shape and gets a different one with a different remedy. The two surfaces never appear together, so nothing forces the collision into view. This is a vocabulary collision rather than a classification defect, which is why the fix is not to change the classifier. Directions, none adopted: have version name the install mode alongside the version string, so the two properties are visible in one place; or rename the build-time 'dev' to something the install-mode vocabulary does not use; or have the foreign refusal say what it examined, since a reader told an entry is not owned cannot tell whether the objection is the symlink, the target, or the absence of recorded provenance. \ No newline at end of file +abcd version reports 'dev' and abcd update calls the same PATH entry 'foreign', and both are correct, which is the problem. Verified 2026-08-23 against ~/.local/bin/abcd, a symlink into a source checkout's bin/abcd-darwin-arm64. abcd version prints 'abcd dev, vintage 5d864c42bad9, up to date'. abcd update on the same entry refuses with shape foreign and the detail that the entry is not something abcd owns. Neither is wrong. The words describe unrelated properties that happen to collide. Version's 'dev' is the build-time version string, internal/core/core.go var Version = "dev", overridden by ldflags at release, so any make-build or go-run binary reports it. Update's classification is about the PATH entry's provenance: internal/core/ahoy/update_target.go resolves the occupant, isDevShimFile in store.go recognises a dev shim only by a byte prefix on the file itself, and an entry that is neither a shim, nor an owned symlink, nor a regular file falls to UpdateTargetForeign. A symlink to a hand-built binary is therefore foreign by construction and correctly so. The legibility failure is that commands/update.md lists dev-shim as its own named refusal shape with its own remedy, abcd ahoy install, so a reader who has just seen 'abcd dev' has every reason to predict that shape and gets a different one with a different remedy. The two surfaces never appear together, so nothing forces the collision into view. This is a vocabulary collision rather than a classification defect, which is why the fix is not to change the classifier. Directions, none adopted: have version name the install mode alongside the version string, so the two properties are visible in one place; or rename the build-time 'dev' to something the install-mode vocabulary does not use; or have the foreign refusal say what it examined, since a reader told an entry is not owned cannot tell whether the objection is the symlink, the target, or the absence of recorded provenance. + +## Grounds + +- pursued: a reader told an entry is foreign can see what was examined and why 'dev' does not mean the dev-shim shape; shown wrong if TestPlanForeignRefusalNamesWhatItExamined finds the detail silent on either From 950218be8405317246ec101a0595f621a60d9775 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:51:17 +0100 Subject: [PATCH 050/107] feat(launch): read the declared artefact kind in every launch verb A managed repository declares what it ships once, in .abcd/config/artefact.json (kind plugin, binary or application; a lockstep list; the site opt-in), and one reader in internal/core/launch validates it for every launch verb and for ahoy (itd-2609150819432059, decisions 1, 2, 8). - `launch --dry-run` refuses a repository with no declaration, naming the file and the accepted kinds rather than a missing-file error. A kind other than plugin scans the tree the release tag would archive (git archive's view of HEAD, export-ignore honoured, links excluded) minus the record namespace, unless it declares an include set, and the report names the tree it scanned. Its lockstep check reads the version-location primary and every declared file, reads no plugin manifest, and refuses a declared file it cannot read. The plugin-only rows report not_armed naming the kind. - ship, archive and receipts read an absent declaration as the plugin shape and refuse an unknown kind before anything is written; ship refuses --payload-dir and archive refuses outright for a non-plugin kind. The cut's derivation, findings gate and deferral read are unchanged for every kind. - abcd declares its own kind: plugin. The archived tree is listed with ls-tree and check-attr rather than by running git archive, which applies the repository's configured filter drivers. Decisions taken here, not in the record: a lockstep entry is a path or {path, json_pointer}, read at the primary's pointer when it names none; an unknown key in the declaration is refused rather than ignored; a non-plugin kind with neither a version-location contract nor a lockstep list holds nothing in lockstep and passes, saying so. Assisted-by: Claude:claude-opus-5-5 --- .abcd/config/artefact.json | 5 + internal/core/launch/artefact.go | 225 ++++++++++++++++++ internal/core/launch/artefact_test.go | 106 +++++++++ internal/core/launch/bundle.go | 49 ++++ internal/core/launch/bundle_archive_test.go | 79 ++++++ internal/core/launch/byte_scan_home_test.go | 1 + internal/core/launch/citations_test.go | 1 + internal/core/launch/dryrun.go | 61 +++-- internal/core/launch/dryrun_kind_test.go | 131 ++++++++++ internal/core/launch/dryrun_polarity_test.go | 2 + internal/core/launch/dryrun_refusals_test.go | 6 +- internal/core/launch/dryrun_test.go | 4 + internal/core/launch/gates.go | 10 +- internal/core/launch/gates_test.go | 6 + internal/core/launch/installsurface_test.go | 1 + internal/core/launch/kind.go | 75 ++++++ internal/core/launch/lockstep.go | 95 ++++++++ .../core/launch/lockstep_declared_test.go | 85 +++++++ internal/core/launch/parity_test.go | 2 + internal/core/launch/render.go | 12 + internal/core/launch/scan_coverage_test.go | 3 + internal/core/launch/ship.go | 21 +- internal/gitutil/archive.go | 91 +++++++ internal/surface/cli/archive.go | 8 + internal/surface/cli/cli.go | 2 + internal/surface/cli/launch_nopayload_test.go | 61 ++++- internal/surface/cli/launch_preflight.go | 36 ++- internal/surface/cli/launch_receipts.go | 3 + internal/surface/cli/ship.go | 11 + internal/surface/cli/ship_payload_test.go | 12 +- 30 files changed, 1160 insertions(+), 44 deletions(-) create mode 100644 .abcd/config/artefact.json create mode 100644 internal/core/launch/artefact.go create mode 100644 internal/core/launch/artefact_test.go create mode 100644 internal/core/launch/bundle_archive_test.go create mode 100644 internal/core/launch/dryrun_kind_test.go create mode 100644 internal/core/launch/kind.go create mode 100644 internal/core/launch/lockstep_declared_test.go create mode 100644 internal/gitutil/archive.go diff --git a/.abcd/config/artefact.json b/.abcd/config/artefact.json new file mode 100644 index 000000000..257c50d23 --- /dev/null +++ b/.abcd/config/artefact.json @@ -0,0 +1,5 @@ +{ + "kind": "plugin", + "lockstep": [], + "site": false +} diff --git a/internal/core/launch/artefact.go b/internal/core/launch/artefact.go new file mode 100644 index 000000000..488f1bb9e --- /dev/null +++ b/internal/core/launch/artefact.go @@ -0,0 +1,225 @@ +package launch + +// artefact.go — the artefact declaration (itd-2609150819432059, decision 1). +// +// A repository abcd manages says once what it ships, in +// .abcd/config/artefact.json, and the launch verbs follow the declaration: the +// bundle the preview scans, the lockstep check, the plugin-only rows and the +// scaffold's file set are chosen by the declared kind. This file is the one +// reader of that declaration. Every launch verb and ahoy go through it, so a +// kind one of them accepts is a kind all of them accept. + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// ArtefactRelPath is the declaration's home, repo-relative. +const ArtefactRelPath = ".abcd/config/artefact.json" + +// maxArtefactBytes caps the guarded read: the declaration is a few lines, and a +// larger file is not one anybody wrote by hand. +const maxArtefactBytes = 64 << 10 + +// ArtefactKind is what a repository ships. +type ArtefactKind string + +// The kinds the first cut accepts. Any other value is refused by name. +const ( + // KindPlugin is the shipped shape: a harness plugin whose payload, manifests + // and marketplace listing the launch gates already judge. + KindPlugin ArtefactKind = "plugin" + // KindBinary is a built program, a Go binary in the first cut. + KindBinary ArtefactKind = "binary" + // KindApplication is an application with its own build and publish steps. + KindApplication ArtefactKind = "application" +) + +// ArtefactKinds is the accepted set, in the order a refusal names it. +var ArtefactKinds = []ArtefactKind{KindPlugin, KindBinary, KindApplication} + +// artefactKindList renders the accepted set for a refusal. +func artefactKindList() string { + names := make([]string, len(ArtefactKinds)) + for i, k := range ArtefactKinds { + names[i] = string(k) + } + return strings.Join(names, ", ") +} + +// ValidArtefactKind reports whether k is one of the accepted kinds. +func ValidArtefactKind(k string) bool { + for _, known := range ArtefactKinds { + if string(known) == k { + return true + } + } + return false +} + +// LockstepFile is one file held in lockstep with the version-location primary. +// Pointer is the RFC-6901 pointer to the version inside it; empty means the +// primary's own pointer. +type LockstepFile struct { + Path string `json:"path"` + Pointer string `json:"json_pointer,omitempty"` +} + +// Artefact is a validated declaration. +type Artefact struct { + Kind ArtefactKind `json:"kind"` + // Lockstep is the declared secondaries a non-plugin kind holds in lockstep + // with its primary (decision 2). A plugin keeps the pinned manifest table. + Lockstep []LockstepFile `json:"lockstep,omitempty"` + // Site is the release-rendered site opt-in (decision 9). It is read and + // validated here and acted on by itd-2609061543533170, not by this intent. + Site bool `json:"site,omitempty"` +} + +// IsPlugin reports whether the declared kind is the plugin shape. +func (a Artefact) IsPlugin() bool { return a.Kind == KindPlugin } + +// ErrNoArtefact reports a repository that has not declared its artefact kind. +// It is carried inside a PreflightError whose message names the declaration's +// home and the accepted kinds, so a caller that recognises it can say more and +// a caller that does not still relays an actionable refusal. +var ErrNoArtefact = errors.New("this repository declares no artefact kind") + +// noArtefactMessage is the absent declaration's refusal. +func noArtefactMessage() string { + return "this repository declares no artefact kind: " + ArtefactRelPath + " is where it is declared, " + + `as {"kind": "<kind>"} with the kind one of ` + artefactKindList() + + "; `abcd ahoy install` writes it (a repository carrying a plugin manifest adopts kind plugin without being asked)" +} + +// artefactKeys are the keys the declaration admits. An unknown key is refused +// rather than ignored: a misspelt "lockstep" would otherwise switch the check +// off without a word. +var artefactKeys = map[string]struct{}{"kind": {}, "lockstep": {}, "site": {}} + +// LoadArtefact reads and validates the repository's artefact declaration. An +// absent file is a PreflightError wrapping ErrNoArtefact; every other fault — +// an unreadable or malformed file, an unknown kind, a lockstep path that is not +// a contained repo-relative path — is a PreflightError naming what is wrong. +// Whether a declared lockstep file can be READ is the lockstep check's to say, +// where the refusal names the path. +func LoadArtefact(repoRoot string) (Artefact, error) { + data, err := fsutil.ReadGuarded(filepath.Join(repoRoot, filepath.FromSlash(ArtefactRelPath)), maxArtefactBytes) + if err != nil { + if os.IsNotExist(err) { + return Artefact{}, &PreflightError{msg: noArtefactMessage(), err: ErrNoArtefact} + } + return Artefact{}, preflight("the artefact declaration %s is unreadable: %v", ArtefactRelPath, pathFreeError(err)) + } + return ParseArtefact(data) +} + +// ParseArtefact validates a declaration's bytes. It is exported for the writer +// in ahoy, which proves what it is about to write reads back. +func ParseArtefact(data []byte) (Artefact, error) { + var raw map[string]json.RawMessage + dec := json.NewDecoder(bytes.NewReader(data)) + if err := dec.Decode(&raw); err != nil || raw == nil || dec.More() { + return Artefact{}, preflight("the artefact declaration %s is not a JSON object", ArtefactRelPath) + } + for key := range raw { + if _, ok := artefactKeys[key]; !ok { + return Artefact{}, preflight("the artefact declaration %s carries an unknown key %q (it admits kind, lockstep and site)", + ArtefactRelPath, key) + } + } + + var art Artefact + var kind string + if err := json.Unmarshal(raw["kind"], &kind); err != nil || kind == "" { + return Artefact{}, preflight("the artefact declaration %s declares no kind: it must name one of %s", + ArtefactRelPath, artefactKindList()) + } + if !ValidArtefactKind(kind) { + return Artefact{}, preflight("the artefact declaration %s names the kind %q, which abcd does not know: the accepted kinds are %s", + ArtefactRelPath, kind, artefactKindList()) + } + art.Kind = ArtefactKind(kind) + + if v, ok := raw["site"]; ok { + if err := json.Unmarshal(v, &art.Site); err != nil { + return Artefact{}, preflight("the artefact declaration %s: site is not a boolean", ArtefactRelPath) + } + } + + if v, ok := raw["lockstep"]; ok { + files, err := parseLockstep(v) + if err != nil { + return Artefact{}, err + } + if len(files) > 0 && art.IsPlugin() { + return Artefact{}, preflight("the artefact declaration %s declares a lockstep list for kind plugin: "+ + "a plugin's lockstep is the pinned manifest table (adr-20), so the list belongs to the other kinds", ArtefactRelPath) + } + art.Lockstep = files + } + return art, nil +} + +// parseLockstep validates the lockstep list: each entry is a repo-relative path, +// or an object naming the path and the pointer to the version inside it. +func parseLockstep(v json.RawMessage) ([]LockstepFile, error) { + var entries []json.RawMessage + if err := json.Unmarshal(v, &entries); err != nil { + return nil, preflight("the artefact declaration %s: lockstep is not a list", ArtefactRelPath) + } + var files []LockstepFile + seen := map[string]struct{}{} + for _, e := range entries { + var f LockstepFile + var p string + obj := json.NewDecoder(bytes.NewReader(e)) + obj.DisallowUnknownFields() + if err := json.Unmarshal(e, &p); err == nil { + f.Path = p + } else if err := obj.Decode(&f); err != nil { + return nil, preflight("the artefact declaration %s: a lockstep entry is neither a path nor {\"path\", \"json_pointer\"}", ArtefactRelPath) + } + // The path is committed configuration data joined onto the repository + // root, so it is held to the containment the version-location + // manifest_path is (gh-488), and it may not name the record namespace, + // which never ships and so can never carry a released version. + if !fsutil.ValidRelPath(f.Path) || pathContainsDeniedSegment(f.Path) { + return nil, preflight("the artefact declaration %s: the lockstep path %q is not a contained repo-relative path outside the record namespace", + ArtefactRelPath, f.Path) + } + if f.Pointer != "" && !strings.HasPrefix(f.Pointer, "/") { + return nil, preflight("the artefact declaration %s: the lockstep json_pointer %q for %s is not an RFC-6901 pointer", + ArtefactRelPath, f.Pointer, f.Path) + } + if _, dup := seen[f.Path]; dup { + return nil, preflight("the artefact declaration %s names the lockstep path %s twice", ArtefactRelPath, f.Path) + } + seen[f.Path] = struct{}{} + files = append(files, f) + } + return files, nil +} + +// MarshalArtefact renders a declaration the way ahoy writes it: indented, with +// an empty lockstep list and the site opt-in spelled out, so the file shows the +// keys it admits. +func MarshalArtefact(art Artefact) []byte { + files := art.Lockstep + if files == nil { + files = []LockstepFile{} + } + doc := struct { + Kind ArtefactKind `json:"kind"` + Lockstep []LockstepFile `json:"lockstep"` + Site bool `json:"site"` + }{art.Kind, files, art.Site} + out, _ := json.MarshalIndent(doc, "", " ") + return append(out, '\n') +} diff --git a/internal/core/launch/artefact_test.go b/internal/core/launch/artefact_test.go new file mode 100644 index 000000000..8ed86dc4d --- /dev/null +++ b/internal/core/launch/artefact_test.go @@ -0,0 +1,106 @@ +package launch + +import ( + "errors" + "strings" + "testing" +) + +// itd-2609150819432059: one reader validates the artefact declaration, and +// every launch verb and ahoy go through it. + +func TestLoadArtefactReadsEachAcceptedKind(t *testing.T) { + for _, kind := range []ArtefactKind{KindPlugin, KindBinary, KindApplication} { + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "`+string(kind)+`"}`) + art, err := LoadArtefact(root) + if err != nil { + t.Fatalf("kind %s: %v", kind, err) + } + if art.Kind != kind || len(art.Lockstep) != 0 || art.Site { + t.Errorf("kind %s: read %+v", kind, art) + } + } +} + +// The absent declaration is its own named refusal: it names the file as the +// declaration's home and the kinds it accepts, never a missing-file error. +func TestLoadArtefactAbsentNamesTheHomeAndTheKinds(t *testing.T) { + _, err := LoadArtefact(t.TempDir()) + if !errors.Is(err, ErrNoArtefact) { + t.Fatalf("err = %v, want ErrNoArtefact", err) + } + for _, want := range []string{ArtefactRelPath, "plugin", "binary", "application", "abcd ahoy install"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } + for _, not := range []string{"no such file", "not found"} { + if strings.Contains(err.Error(), not) { + t.Errorf("the refusal reads as a missing-file error (%q): %v", not, err) + } + } +} + +func TestLoadArtefactRefusesAnUnknownKindNamingTheAcceptedSet(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "container-image"}`) + _, err := LoadArtefact(root) + if err == nil || errors.Is(err, ErrNoArtefact) { + t.Fatalf("err = %v, want a refusal of the unknown kind", err) + } + for _, want := range []string{`"container-image"`, "plugin, binary, application"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q: %v", want, err) + } + } +} + +func TestLoadArtefactReadsTheLockstepListAndTheSiteOptIn(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, + `{"kind": "binary", "site": true, "lockstep": ["package.json", {"path": "app/meta.json", "json_pointer": "/release/version"}]}`) + art, err := LoadArtefact(root) + if err != nil { + t.Fatal(err) + } + want := []LockstepFile{{Path: "package.json"}, {Path: "app/meta.json", Pointer: "/release/version"}} + if len(art.Lockstep) != 2 || art.Lockstep[0] != want[0] || art.Lockstep[1] != want[1] || !art.Site { + t.Fatalf("read %+v, want lockstep %+v and site true", art, want) + } +} + +func TestLoadArtefactRefusesMalformedDeclarations(t *testing.T) { + cases := map[string]struct{ body, want string }{ + "not json": {`{kind: plugin`, "not a JSON object"}, + "not an object": {`["plugin"]`, "not a JSON object"}, + "no kind": {`{"lockstep": []}`, "declares no kind"}, + "kind not a string": {`{"kind": 3}`, "declares no kind"}, + "unknown key": {`{"kind": "binary", "lockstop": []}`, `"lockstop"`}, + "lockstep not a list": {`{"kind": "binary", "lockstep": "a.json"}`, "lockstep"}, + "escaping path": {`{"kind": "binary", "lockstep": ["../a.json"]}`, "../a.json"}, + "absolute path": {`{"kind": "binary", "lockstep": ["/etc/a.json"]}`, "/etc/a.json"}, + "record namespace": {`{"kind": "binary", "lockstep": [".abcd/config/x.json"]}`, ".abcd/config/x.json"}, + "bad pointer": {`{"kind": "binary", "lockstep": [{"path": "a.json", "json_pointer": "version"}]}`, "json_pointer"}, + "duplicate path": {`{"kind": "binary", "lockstep": ["a.json", "a.json"]}`, "twice"}, + "site not a boolean": {`{"kind": "binary", "site": "yes"}`, "site"}, + "plugin with a list": {`{"kind": "plugin", "lockstep": ["a.json"]}`, "plugin"}, + "misspelt entry key": {`{"kind": "binary", "lockstep": [{"path": "a.json", "pointer": "/v"}]}`, "lockstep entry"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, c.body) + _, err := LoadArtefact(root) + if err == nil { + t.Fatalf("accepted %s", c.body) + } + if errors.Is(err, ErrNoArtefact) { + t.Fatalf("a malformed declaration read as an absent one: %v", err) + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("the refusal does not name %q: %v", c.want, err) + } + }) + } +} diff --git a/internal/core/launch/bundle.go b/internal/core/launch/bundle.go index 81789759b..99952463e 100644 --- a/internal/core/launch/bundle.go +++ b/internal/core/launch/bundle.go @@ -1025,3 +1025,52 @@ func sortBundle(b *Bundle) { return b.Rejected[i].Reason < b.Rejected[j].Reason }) } + +// ExcludedSymlink is a link in the archived tree. An archive carries a link as +// the path it names, not as content, so there is nothing of it to scan, and +// reading through it would scan whatever the working tree's target is instead. +const ExcludedSymlink ExcludedReason = "symlink" + +// ArchiveTreeDescription names the tree a non-plugin kind's preview scans, for +// the report line that says which tree was scanned. +const ArchiveTreeDescription = "the tree the release tag would archive (git archive's view of HEAD, export-ignore honoured), minus the record namespace" + +// ResolveArchiveBundle is the bundle of a non-plugin artefact kind that declares +// no payload include config (itd-2609150819432059, decision 8): the files an +// archive of HEAD would carry, classified under the same structural deny a +// plugin payload is held to. A path with a denied segment is +// excluded(denied_namespace), exactly as the plugin resolver excludes it; a link +// is excluded(symlink); a control character in a path is rejected, as it is in a +// plugin payload. Every other file is included, read from the working tree the +// way a plugin payload's files are, so an uncommitted edit is what the +// dirty-tree gate reports rather than something this listing hides. +func ResolveArchiveBundle(repoRoot string) (Bundle, error) { + absRoot, err := filepath.Abs(repoRoot) + if err != nil { + return Bundle{}, err + } + if real, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = real + } + entries, err := gitutil.ArchiveTree(absRoot, "HEAD") + if err != nil { + return Bundle{}, preflight("the tree the release tag would archive could not be listed: %v", err) + } + var b Bundle + for _, e := range entries { + switch { + case hasControlChar(e.Path): + b.Rejected = append(b.Rejected, RejectedFile{LogicalPath: e.Path, Reason: RejectedControlChar}) + case pathContainsDeniedSegment(e.Path): + b.Excluded = append(b.Excluded, ExcludedFile{LogicalPath: e.Path, Reason: ExcludedDeniedNamespace}) + case e.Mode == "120000": + b.Excluded = append(b.Excluded, ExcludedFile{LogicalPath: e.Path, Reason: ExcludedSymlink}) + default: + b.Included = append(b.Included, IncludedFile{ + LogicalPath: e.Path, ResolvedPath: filepath.Join(absRoot, filepath.FromSlash(e.Path)), GitMode: e.Mode, + }) + } + } + sortBundle(&b) + return b, nil +} diff --git a/internal/core/launch/bundle_archive_test.go b/internal/core/launch/bundle_archive_test.go new file mode 100644 index 000000000..f6464ac49 --- /dev/null +++ b/internal/core/launch/bundle_archive_test.go @@ -0,0 +1,79 @@ +package launch + +import ( + "os" + "path/filepath" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// itd-2609150819432059 AC8 / decision 8: a non-plugin kind with no payload +// include config previews the tree the release tag would archive — git +// archive's view of HEAD, export-ignore honoured — with the record namespace +// denied by the same DenyNamespaces a plugin payload is held to. +func TestResolveArchiveBundleIsTheArchivedTreeMinusTheRecordNamespace(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("main.go", "package main\n") + r.Write("cmd/tool/run.sh", "#!/bin/sh\n") + r.Write(".abcd/work/CONTEXT.md", "the record\n") + r.Write("docs/.abcd/nested.md", "a nested record namespace\n") + r.Write("testdata/big.bin", "fixture\n") + r.Write("vendor-notes/a.txt", "export-ignored directory\n") + r.Write(".gitattributes", "testdata/big.bin export-ignore\nvendor-notes export-ignore\n") + if err := os.Chmod(filepath.Join(r.Root(), "cmd/tool/run.sh"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("main.go", filepath.Join(r.Root(), "link.go")); err != nil { + t.Fatal(err) + } + r.Commit("the tree a tag would archive") + // Present in the working tree, absent from HEAD: never archived. + r.Write("untracked.txt", "not committed\n") + + b, err := ResolveArchiveBundle(r.Root()) + if err != nil { + t.Fatal(err) + } + included := map[string]string{} + for _, f := range b.Included { + included[f.LogicalPath] = f.GitMode + } + want := map[string]string{".gitattributes": "100644", "main.go": "100644", "cmd/tool/run.sh": "100755"} + if len(included) != len(want) { + t.Fatalf("included %v, want %v", included, want) + } + for p, mode := range want { + if included[p] != mode { + t.Errorf("included[%s] = %q, want %q (all: %v)", p, included[p], mode, included) + } + } + excluded := map[string]ExcludedReason{} + for _, e := range b.Excluded { + excluded[e.LogicalPath] = e.Reason + } + for p, reason := range map[string]ExcludedReason{ + ".abcd/work/CONTEXT.md": ExcludedDeniedNamespace, + "docs/.abcd/nested.md": ExcludedDeniedNamespace, + "link.go": ExcludedSymlink, + } { + if excluded[p] != reason { + t.Errorf("excluded[%s] = %q, want %q (all: %v)", p, excluded[p], reason, excluded) + } + } + for _, p := range []string{"testdata/big.bin", "vendor-notes/a.txt", "untracked.txt"} { + if _, ok := included[p]; ok { + t.Errorf("%s is not in the archived tree but was included", p) + } + } + if len(b.Rejected) != 0 { + t.Errorf("rejected %v, want none", b.Rejected) + } +} + +func TestResolveArchiveBundleWithNoCommitIsAPreflightFault(t *testing.T) { + r := gittest.NewRepo(t) + if _, err := ResolveArchiveBundle(r.Root()); err == nil { + t.Fatal("a repository with no HEAD has no tree to archive, and the preview must say so") + } +} diff --git a/internal/core/launch/byte_scan_home_test.go b/internal/core/launch/byte_scan_home_test.go index 6015e32e7..737125453 100644 --- a/internal/core/launch/byte_scan_home_test.go +++ b/internal/core/launch/byte_scan_home_test.go @@ -17,6 +17,7 @@ func TestBytesAndTextAgreeOnAnAlnumPrecededHome(t *testing.T) { t.Run(home, func(t *testing.T) { t.Setenv("HOME", home) root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["docs"]}`) // Plain bytes, so the .md is text-scanned and the .png (skip-listed // by extension) is byte-scanned over the same content; the home is diff --git a/internal/core/launch/citations_test.go b/internal/core/launch/citations_test.go index e40246b85..ed75eff4b 100644 --- a/internal/core/launch/citations_test.go +++ b/internal/core/launch/citations_test.go @@ -8,6 +8,7 @@ import ( func runCitationGate(t *testing.T, pre *CitationPreflight) (GateSummary, []string) { t.Helper() repo := t.TempDir() + writeFile(t, repo, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, repo, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) writeFile(t, repo, "commands/x.md", "# doc\n") report, err := DryRun(DryRunRequest{RepoRoot: repo, Version: "v0.1.0", Citations: pre}) diff --git a/internal/core/launch/dryrun.go b/internal/core/launch/dryrun.go index d863c6298..7521a8d48 100644 --- a/internal/core/launch/dryrun.go +++ b/internal/core/launch/dryrun.go @@ -1,8 +1,6 @@ package launch import ( - "path/filepath" - "github.com/intentdriven/abcd/internal/adapter/scanner" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -62,12 +60,17 @@ type GateSummary struct { // DryRunReport is the full dry-run preview. No artefact is written. type DryRunReport struct { - Version string `json:"version"` - Bundle Bundle `json:"bundle"` - Scan scanner.ScanResult `json:"scan"` - Lockstep LockstepResult `json:"lockstep"` - Retention RetentionPlan `json:"retention"` - Smoke SmokeReport `json:"smoke"` + Version string `json:"version"` + // Kind is the declared artefact kind the preview ran against. + Kind ArtefactKind `json:"kind"` + // ScannedTree names the tree the bundle and its scan cover: the plugin + // payload, or the tree the release tag would archive. + ScannedTree string `json:"scanned_tree"` + Bundle Bundle `json:"bundle"` + Scan scanner.ScanResult `json:"scan"` + Lockstep LockstepResult `json:"lockstep"` + Retention RetentionPlan `json:"retention"` + Smoke SmokeReport `json:"smoke"` // DeepSmoke is the deep installability tier, present when it was asked for. DeepSmoke *DeepSmokeReport `json:"deep_smoke,omitempty"` // Parity is the file-level diff against the previous release's payload. @@ -92,12 +95,22 @@ type DryRunReport struct { func DryRun(req DryRunRequest) (DryRunReport, error) { var report DryRunReport - bundle, err := ResolveBundle(req.RepoRoot, nil) + // The declaration first: every read below is chosen by the declared kind, + // and a repository that has not declared one is told where to, not handed + // the first missing file a plugin-shaped read would trip on + // (itd-2609150819432059 AC1). + art, err := LoadArtefact(req.RepoRoot) + if err != nil { + return DryRunReport{}, err // preflight fault only + } + report.Kind = art.Kind + + bundle, tree, err := kindBundle(req.RepoRoot, art) if err != nil { return DryRunReport{}, err // preflight fault only } - report.Bundle = bundle - policy, err := LoadGatePolicy(req.RepoRoot) + report.Bundle, report.ScannedTree = bundle, tree + policy, err := kindGatePolicy(req.RepoRoot, art) if err != nil { return DryRunReport{}, err // preflight fault only } @@ -110,8 +123,7 @@ func DryRun(req DryRunRequest) (DryRunReport, error) { // accuse a correct repository of drift and prescribe the exact key the ADR // forbids. The public polarity belongs over the rendered payload, where // RenderPayload applies it to its own output. - vlPath := filepath.Join(req.RepoRoot, versionLocationRelPath) - lockstep := CheckLockstep(TreeDev, req.RepoRoot, vlPath) + lockstep := kindLockstep(TreeDev, req.RepoRoot, art) report.Lockstep = lockstep report.Version = req.Version @@ -121,19 +133,26 @@ func DryRun(req DryRunRequest) (DryRunReport, error) { // in the tree but excluded from the payload is exactly the break it exists // to catch. It subsumes itd-65's placeholder `plugin.json-parse` gate, which // asserted a strict subset of what the light tier asserts. - smoke := SmokeLight(NewBundleTree(bundle)) + // A kind that ships no plugin has no plugin surface to install, so the + // smoke is not armed for it rather than failing on a manifest it never had. + smoke := SmokeReport{Tier: SmokeTierLight, OK: true} + smokeRow := pluginOnlyRow("installability-smoke", art.Kind) + if art.IsPlugin() { + smoke = SmokeLight(NewBundleTree(bundle)) + smokeRow = GateSummary{Name: "installability-smoke", Status: "ran", Detail: smokeDetail(smoke)} + } report.Smoke = smoke // The preview has no override flag, so the dirty-tree gate runs at its // refusing default: a dirty tree is reported as what a cut would refuse on. suite := runGateSuite(suiteRequest{ RepoRoot: req.RepoRoot, Bundle: bundle, Dirty: DirtyRefuse, - DocAudit: req.DocAudit, Policy: policy, + DocAudit: req.DocAudit, Policy: policy, Kind: art.Kind, }) report.Gates = append([]GateSummary{ {Name: "secret+pii-scan", Status: "ran", Detail: scanDetail(scan)}, - {Name: "installability-smoke", Status: "ran", Detail: smokeDetail(smoke)}, + smokeRow, }, suite.Gates...) report.Gates = append(report.Gates, citationGate(req.Citations), @@ -142,15 +161,21 @@ func DryRun(req DryRunRequest) (DryRunReport, error) { receiptGate(req.Receipts), ) - if req.DeepSmoke != nil { + switch { + case req.DeepSmoke != nil && art.IsPlugin(): deep := smokeDeepOverBundle(bundle, req.DeepSmoke) report.DeepSmoke = &deep report.Gates = append(report.Gates, deepSmokeGate(&deep)) + case req.DeepSmoke != nil: + report.Gates = append(report.Gates, pluginOnlyRow("installability-smoke-deep", art.Kind)) } - if req.Parity != nil { + switch { + case req.Parity != nil && art.IsPlugin(): parity := PayloadParity(req.RepoRoot, bundle, *req.Parity) report.Parity = &parity report.Gates = append(report.Gates, parityGate(&parity)) + case req.Parity != nil: + report.Gates = append(report.Gates, pluginOnlyRow("payload-parity", art.Kind)) } report.WouldRefuseOn = wouldRefuseOn(bundle, scan, lockstep, report.Retention, smoke) diff --git a/internal/core/launch/dryrun_kind_test.go b/internal/core/launch/dryrun_kind_test.go new file mode 100644 index 000000000..00ba2c7db --- /dev/null +++ b/internal/core/launch/dryrun_kind_test.go @@ -0,0 +1,131 @@ +package launch + +import ( + "errors" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// itd-2609150819432059: the preview runs against the declared artefact kind. + +// AC1: no declaration is a named refusal, never a missing-file error, and the +// preview writes nothing. +func TestDryRunWithNoArtefactDeclarationRefusesNamingItsHome(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) + writeFile(t, root, "commands/x.md", "doc\n") + _, err := DryRun(DryRunRequest{RepoRoot: root, Version: "1.0.0"}) + if !errors.Is(err, ErrNoArtefact) { + t.Fatalf("err = %v, want the no-declaration refusal", err) + } + if !strings.Contains(err.Error(), ArtefactRelPath) || !strings.Contains(err.Error(), "plugin, binary, application") { + t.Errorf("the refusal does not name the declaration's home and the kinds: %v", err) + } +} + +// AC9: a kind the binary does not know refuses, naming the kind and the set. +func TestDryRunRefusesAnUnknownKind(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "wheel"}`) + _, err := DryRun(DryRunRequest{RepoRoot: root, Version: "1.0.0"}) + if err == nil || !strings.Contains(err.Error(), `"wheel"`) || !strings.Contains(err.Error(), "plugin, binary, application") { + t.Fatalf("err = %v, want a refusal naming the kind and the accepted set", err) + } +} + +// binaryRepo is a managed Go binary: no plugin manifest, no payload include +// config, a declared lockstep file, and a record namespace that must not ship. +func binaryRepo(t *testing.T) *gittest.Repo { + t.Helper() + r := gittest.NewRepo(t) + r.Write(ArtefactRelPath, `{"kind": "binary", "lockstep": ["app/meta.json"]}`) + r.Write(versionLocationRelPath, `{"outcome":"accept","blocked":false,"manifest_path":"version.json","json_pointer":"/version"}`) + r.Write("version.json", `{"name":"tool"}`) + r.Write("app/meta.json", `{"bundle":"tool"}`) + r.Write("main.go", "package main\n\nfunc main() {}\n") + r.Write("README.md", "# tool\n") + r.Write(".abcd/work/CONTEXT.md", "token = "+fakeSecret+"\n") + r.Commit("a managed binary") + return r +} + +// AC2 + AC8: the declared lockstep is checked with no plugin manifest read and +// no payload include config required, and the scan runs over the archived tree +// minus the record namespace, with the report saying which tree it scanned. +func TestDryRunForABinaryScansTheArchivedTreeAndChecksTheDeclaredLockstep(t *testing.T) { + r := binaryRepo(t) + report, err := DryRun(DryRunRequest{RepoRoot: r.Root(), Version: "1.0.0", ExistingTags: []Semver{}}) + if err != nil { + t.Fatalf("a declared binary with no payload config must preview: %v", err) + } + if report.Kind != KindBinary || report.ScannedTree != ArchiveTreeDescription { + t.Errorf("kind %q, scanned tree %q", report.Kind, report.ScannedTree) + } + included := map[string]bool{} + for _, f := range report.Bundle.Included { + included[f.LogicalPath] = true + } + for _, p := range []string{"main.go", "README.md", "version.json", "app/meta.json"} { + if !included[p] { + t.Errorf("%s is in the archived tree but not in the bundle: %v", p, included) + } + } + for p := range included { + if strings.HasPrefix(p, ".abcd/") { + t.Errorf("the record namespace reached the bundle: %s", p) + } + } + if report.Scan.HardFails != 0 { + t.Errorf("the secret inside .abcd/ was scanned as if it shipped: %+v", report.Scan.Findings) + } + if !report.Lockstep.OK { + t.Errorf("lockstep %+v, want OK", report.Lockstep) + } + for _, reason := range report.WouldRefuseOn { + if strings.Contains(reason, "marketplace") || strings.Contains(reason, "plugin.json") || + strings.Contains(reason, "launch-payload") || strings.Contains(reason, "installability") { + t.Errorf("a plugin-only concern refused a binary: %s", reason) + } + } + rows := map[string]GateSummary{} + for _, g := range report.Gates { + rows[g.Name] = g + } + for _, name := range []string{"installability-smoke", gateHookCompliant} { + if rows[name].Status != "not_armed" || !strings.Contains(rows[name].Detail, "binary") { + t.Errorf("row %s = %+v, want not_armed naming the kind", name, rows[name]) + } + } + + // A declared path that cannot be read is refused by name. + r.Write(ArtefactRelPath, `{"kind": "binary", "lockstep": ["app/meta.json", "gone.json"]}`) + r.Commit("declare a file that does not exist") + report, err = DryRun(DryRunRequest{RepoRoot: r.Root(), Version: "1.0.0", ExistingTags: []Semver{}}) + if err != nil { + t.Fatal(err) + } + found := false + for _, reason := range report.WouldRefuseOn { + found = found || (strings.Contains(reason, "lockstep") && strings.Contains(reason, "gone.json")) + } + if !found { + t.Errorf("an unreadable declared lockstep path was not refused by name: %v", report.WouldRefuseOn) + } +} + +// A plugin says the payload include set is what it scanned. +func TestDryRunForAPluginNamesThePayloadTree(t *testing.T) { + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) + writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) + writeFile(t, root, "commands/x.md", "doc\n") + report, err := DryRun(DryRunRequest{RepoRoot: root, Version: "1.0.0"}) + if err != nil { + t.Fatal(err) + } + if report.Kind != KindPlugin || report.ScannedTree != PayloadTreeDescription { + t.Errorf("kind %q, scanned tree %q", report.Kind, report.ScannedTree) + } +} diff --git a/internal/core/launch/dryrun_polarity_test.go b/internal/core/launch/dryrun_polarity_test.go index 0dfae690b..561db7c0b 100644 --- a/internal/core/launch/dryrun_polarity_test.go +++ b/internal/core/launch/dryrun_polarity_test.go @@ -39,6 +39,7 @@ func TestDryRunAssertsTheDevPolarity(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "README.md"]}`) writeFile(t, root, "README.md", "clean readme\n") @@ -81,6 +82,7 @@ func TestDryRunAssertsTheDevPolarity(t *testing.T) { // on every correct repository and then refuse retention for it. func TestDryRunVersionComesFromTheCaller(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "README.md"]}`) writeFile(t, root, "README.md", "clean readme\n") writeLockstepTree(t, root, "", "", "") diff --git a/internal/core/launch/dryrun_refusals_test.go b/internal/core/launch/dryrun_refusals_test.go index cd6e49cf0..bd7b2bd6b 100644 --- a/internal/core/launch/dryrun_refusals_test.go +++ b/internal/core/launch/dryrun_refusals_test.go @@ -14,6 +14,7 @@ import ( // preview "nothing to prune" for a repository whose tags were never seen. func TestPreviewRetentionRefusesWhenTagsAreUnreadable(t *testing.T) { root := t.TempDir() // not a git repository: the tag listing fails + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "README.md"]}`) writeFile(t, root, "README.md", "readme\n") writeLockstepTree(t, root, "", "", "") @@ -34,7 +35,9 @@ func TestPreviewRetentionRefusesWhenTagsAreUnreadable(t *testing.T) { // repository with no launch payload gets an error the front door can recognise // and explain, not only a raw missing-file message. func TestMissingPayloadConfigIsNamed(t *testing.T) { - _, err := DryRun(DryRunRequest{RepoRoot: t.TempDir()}) + root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) + _, err := DryRun(DryRunRequest{RepoRoot: root}) if !errors.Is(err, ErrNoLaunchPayload) { t.Fatalf("a missing include config must carry ErrNoLaunchPayload, got %v", err) } @@ -55,6 +58,7 @@ func anyReasonContains(reasons []string, fragment string) bool { // the checkout never saw. func TestPreviewRetentionRefusesInAShallowCheckout(t *testing.T) { r := gittest.NewRepo(t) + r.Write(ArtefactRelPath, `{"kind": "plugin"}`) r.Write(".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "README.md"]}`) r.Write("README.md", "readme\n") writeLockstepTree(t, r.Root(), "", "", "") diff --git a/internal/core/launch/dryrun_test.go b/internal/core/launch/dryrun_test.go index ee95fbe45..6ebe7dd3e 100644 --- a/internal/core/launch/dryrun_test.go +++ b/internal/core/launch/dryrun_test.go @@ -17,6 +17,7 @@ const fakeSecret = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ab" // (exit-0 semantics — a preview never blocks). func TestDryRunSecretRefusesButExitsZero(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) writeFile(t, root, "commands/x.md", "# doc\ntoken = "+fakeSecret+"\n") @@ -51,6 +52,7 @@ func TestDryRunSecretRefusesButExitsZero(t *testing.T) { // TestShipBlocksOnSecret is the ship-side of brief AC 1: the same tree blocks. func TestShipBlocksOnSecret(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) writeFile(t, root, "commands/x.md", "token = "+fakeSecret+"\n") @@ -82,6 +84,7 @@ func TestShipCleanWouldPublish(t *testing.T) { root := repo.Root() // The payload must carry .claude-plugin: a bundle without the manifests is // not an installable plugin, which the installability gate now says out loud. + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "commands", "README.md"]}`) writeFile(t, root, "commands/a.md", "clean content\n") writeFile(t, root, "README.md", "clean readme\n") @@ -108,6 +111,7 @@ func TestShipCleanWouldPublish(t *testing.T) { // WouldRefuseOn, and blocks Ship instead of letting it would-publish. func TestZeroCoverageRefuses(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) writeFile(t, root, "commands/a.md", "clean content\n") writeFile(t, root, ".abcd/config/pii.json", `{"skip_extensions": [".md"]}`) diff --git a/internal/core/launch/gates.go b/internal/core/launch/gates.go index 0c1a2afea..fcc9e034d 100644 --- a/internal/core/launch/gates.go +++ b/internal/core/launch/gates.go @@ -144,6 +144,10 @@ type suiteRequest struct { Dirty DirtyPolicy DocAudit *DocAuditPreflight Policy GatePolicy + // Kind is the declared artefact kind. Empty is the plugin shape every + // caller that predates the declaration assumes; any other kind reports the + // rows that judge a plugin payload as not armed. + Kind ArtefactKind } // suiteResult is everything the suite found, collected before anything decides. @@ -203,7 +207,11 @@ func runGateSuite(req suiteRequest) suiteResult { } warn(docAuditGate(req.DocAudit)) - warn(hookComplianceGate(req.Bundle)) + if req.Kind == "" || req.Kind == KindPlugin { + warn(hookComplianceGate(req.Bundle)) + } else { + res.Gates = append(res.Gates, pluginOnlyRow(gateHookCompliant, req.Kind)) + } return res } diff --git a/internal/core/launch/gates_test.go b/internal/core/launch/gates_test.go index 6265ac8fd..1347fee2e 100644 --- a/internal/core/launch/gates_test.go +++ b/internal/core/launch/gates_test.go @@ -48,6 +48,7 @@ func anyContains(reasons []string, fragments ...string) bool { func docsFixture(t *testing.T) string { t.Helper() root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "docs", "commands", "README.md"]}`) writeFile(t, root, "README.md", "# readme\n\nThe tool reads the record.\n") @@ -192,6 +193,7 @@ func TestNarrationGatePassesPresentTense(t *testing.T) { func dirtyRepo(t *testing.T) *gittest.Repo { t.Helper() r := gittest.NewRepo(t) + r.Write(ArtefactRelPath, `{"kind": "plugin"}`) r.Write(".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "docs", "README.md"]}`) r.Write(".abcd/config/version-location.json", `{"manifest_path": ".claude-plugin/plugin.json", "json_pointer": "/version"}`) r.Write(".claude-plugin/plugin.json", `{"name": "abcd"}`) @@ -272,6 +274,7 @@ func TestDirtyTreeGateFailsClosedOffAGitTree(t *testing.T) { // nothing, unless the repository configures the suite strict. func TestWarnRowsSurfaceWithoutBlocking(t *testing.T) { root := docsFixture(t) + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "docs", "hooks", "scripts", "README.md"]}`) writeFile(t, root, "hooks/hooks.json", @@ -302,6 +305,7 @@ func TestWarnRowsSurfaceWithoutBlocking(t *testing.T) { } } + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "docs", "hooks", "scripts", "README.md"], "strict_warnings": true}`) strict, err := DryRun(DryRunRequest{RepoRoot: root, Version: "1.2.3", DocAudit: audit}) @@ -312,6 +316,7 @@ func TestWarnRowsSurfaceWithoutBlocking(t *testing.T) { t.Errorf("a strict suite must refuse on its warnings, got %v", strict.WouldRefuseOn) } + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "docs", "README.md"], "strict_warnings": "yes"}`) if _, err := DryRun(DryRunRequest{RepoRoot: root, Version: "1.2.3"}); err == nil { @@ -442,6 +447,7 @@ func TestWritePreflightReportLandsInTheLocalTier(t *testing.T) { // manifest. Neither may read it as a clean pass. func TestHookRowFindsAnUnparseableHooksConfig(t *testing.T) { root := docsFixture(t) + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "docs", "hooks", "README.md"]}`) writeFile(t, root, "hooks/hooks.json", "{not json at all") diff --git a/internal/core/launch/installsurface_test.go b/internal/core/launch/installsurface_test.go index bd84e07a4..17e858a5f 100644 --- a/internal/core/launch/installsurface_test.go +++ b/internal/core/launch/installsurface_test.go @@ -197,6 +197,7 @@ func writeSurfaceFixture(t *testing.T, root string, files map[string]string) { } list += `"` + inc + `"` } + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [`+list+`]}`) plugin := `{"name": "abcd"` if extra != "" { diff --git a/internal/core/launch/kind.go b/internal/core/launch/kind.go new file mode 100644 index 000000000..b96b0b6d0 --- /dev/null +++ b/internal/core/launch/kind.go @@ -0,0 +1,75 @@ +package launch + +// kind.go — what the declared artefact kind changes about a launch run +// (itd-2609150819432059). The gate's inputs — the ledger, the anchor tag, the +// version location — are kind-independent; only the reads that bind a run to a +// plugin are chosen here: the bundle a run scans, the lockstep table, and the +// rows that judge a plugin payload. + +import ( + "errors" + "os" + "path/filepath" +) + +// PayloadTreeDescription names the tree a plugin's preview scans. +const PayloadTreeDescription = "the plugin payload (the include set in .abcd/config/launch-payload.json)" + +// ErrNotAPlugin reports a plugin-only operation — staging or archiving a plugin +// payload — asked of a repository that declares another artefact kind. +var ErrNotAPlugin = errors.New("the repository does not declare a plugin") + +// LoadArtefactOrPlugin reads the declaration for the verbs that predate it and +// stay lenient about its absence: an absent file is the plugin shape they have +// always assumed, while a present file is held to the one reader like anywhere +// else, so an unknown kind refuses every verb. +func LoadArtefactOrPlugin(repoRoot string) (Artefact, error) { + art, err := LoadArtefact(repoRoot) + if errors.Is(err, ErrNoArtefact) { + return Artefact{Kind: KindPlugin}, nil + } + return art, err +} + +// kindBundle resolves the bundle a declared kind previews and names the tree it +// is. A plugin resolves its payload include set. Any other kind resolves that +// same set when it declares one, and otherwise the tree the release tag would +// archive (decision 8): an empty include set is not a refusal for a kind that +// ships no plugin payload. +func kindBundle(repoRoot string, art Artefact) (Bundle, string, error) { + if !art.IsPlugin() { + if _, err := os.Lstat(filepath.Join(repoRoot, includeConfigRelPath)); os.IsNotExist(err) { + b, err := ResolveArchiveBundle(repoRoot) + return b, ArchiveTreeDescription, err + } + } + b, err := ResolveBundle(repoRoot, nil) + return b, PayloadTreeDescription, err +} + +// kindGatePolicy reads the suite's policy. A non-plugin kind with no payload +// include config has nowhere to configure the suite, and runs it at the default. +func kindGatePolicy(repoRoot string, art Artefact) (GatePolicy, error) { + policy, err := LoadGatePolicy(repoRoot) + if err != nil && !art.IsPlugin() && errors.Is(err, ErrNoLaunchPayload) { + return GatePolicy{}, nil + } + return policy, err +} + +// kindLockstep checks the source tree's lockstep for the declared kind: the +// pinned plugin-manifest table for a plugin, the declared list for any other. +func kindLockstep(tree LockstepTree, repoRoot string, art Artefact) LockstepResult { + vl := filepath.Join(repoRoot, versionLocationRelPath) + if art.IsPlugin() { + return CheckLockstep(tree, repoRoot, vl) + } + return CheckDeclaredLockstep(tree, repoRoot, vl, art.Lockstep) +} + +// pluginOnlyRow is the row a plugin-only gate reports for another kind: not +// armed, and why, so a reader sees the row was considered rather than missed. +func pluginOnlyRow(name string, kind ArtefactKind) GateSummary { + return GateSummary{Name: name, Status: "not_armed", + Detail: "the declared artefact kind is " + string(kind) + " (" + ArtefactRelPath + "), and this row judges a plugin payload"} +} diff --git a/internal/core/launch/lockstep.go b/internal/core/launch/lockstep.go index 0a4fb0730..748d2b935 100644 --- a/internal/core/launch/lockstep.go +++ b/internal/core/launch/lockstep.go @@ -286,3 +286,98 @@ func checkDev(primaryPath, primaryPtr string, primaryDoc, marketplace any) []str } return drifts } + +// CheckDeclaredLockstep is the lockstep check for a non-plugin artefact kind +// (itd-2609150819432059, decision 2): the primary is read from +// version-location.json exactly as CheckLockstep reads it, and the pinned +// plugin-manifest table is replaced by the files the artefact declaration names. +// No plugin manifest is read. +// +// Each declared file is a JSON document carrying the version at its own pointer, +// or at the primary's when it names none. The polarities are CheckLockstep's: +// DEV requires every key ABSENT (adr-19), PUBLIC requires the primary present as +// strict SemVer and every secondary to agree with it. A declared file that cannot +// be read or parsed is unreadable (exit 2) and the detail names it. +// +// A kind that declares no lockstep list and carries no version-location contract +// holds nothing in lockstep, and the result is an OK that says so. A declared +// list with no contract to read the primary from is unreadable: there is nothing +// for the list to agree with. +func CheckDeclaredLockstep(tree LockstepTree, repoRoot, versionLocationPath string, files []LockstepFile) LockstepResult { + res := LockstepResult{Tree: tree} + + decision, err := loadJSON(versionLocationPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) && len(files) == 0 { + res.OK = true + res.Detail = "no version-location contract and no declared lockstep list: nothing is held in lockstep" + return res + } + return unreadable(res, "version-location.json not readable: "+err.Error()) + } + primaryPath, primaryPtr, verr := validateVersionLocation(decision) + if verr != "" { + return unreadable(res, verr) + } + primaryDoc, err := loadJSON(filepath.Join(repoRoot, primaryPath)) + if err != nil { + return unreadable(res, "primary manifest "+primaryPath+" not readable: "+err.Error()) + } + + type located struct { + file, ptr string + doc any + } + secondaries := make([]located, 0, len(files)) + for _, f := range files { + doc, err := loadJSON(filepath.Join(repoRoot, filepath.FromSlash(f.Path))) + if err != nil { + return unreadable(res, "declared lockstep file "+f.Path+" not readable: "+err.Error()) + } + ptr := f.Pointer + if ptr == "" { + ptr = primaryPtr + } + secondaries = append(secondaries, located{f.Path, ptr, doc}) + } + + var drifts []string + primVal, primPresent := resolvePointer(primaryDoc, primaryPtr) + if tree == TreePublic { + primStr, isStr := primVal.(string) + expectedOK := primPresent && isStr && IsStrictSemver(primStr) + if !expectedOK { + drifts = append(drifts, fmt.Sprintf("DRIFT public %s%s: expected a present strict-SemVer version string, got %s", + primaryPath, primaryPtr, fmtValue(primVal, primPresent))) + } + for _, s := range secondaries { + v, present := resolvePointer(s.doc, s.ptr) + switch { + case expectedOK && (!present || v != any(primStr)): + drifts = append(drifts, fmt.Sprintf("DRIFT public %s%s: expected %s (from primary), got %s", + s.file, s.ptr, fmtValue(primStr, true), fmtValue(v, present))) + case !expectedOK && present: + drifts = append(drifts, fmt.Sprintf("DRIFT public %s%s: present but primary version is unreadable, got %s", + s.file, s.ptr, fmtValue(v, present))) + } + } + } else { + if primPresent { + drifts = append(drifts, fmt.Sprintf("DRIFT dev %s%s: adr-19 requires this key ABSENT in the dev tree, got %s", + primaryPath, primaryPtr, fmtValue(primVal, primPresent))) + } + for _, s := range secondaries { + if v, present := resolvePointer(s.doc, s.ptr); present { + drifts = append(drifts, fmt.Sprintf("DRIFT dev %s%s: adr-19 requires this key ABSENT in the dev tree, got %s", + s.file, s.ptr, fmtValue(v, present))) + } + } + } + if len(drifts) > 0 { + res.Drifts = drifts + res.ExitCode = 1 + return res + } + res.OK = true + return res +} diff --git a/internal/core/launch/lockstep_declared_test.go b/internal/core/launch/lockstep_declared_test.go new file mode 100644 index 000000000..67963fa80 --- /dev/null +++ b/internal/core/launch/lockstep_declared_test.go @@ -0,0 +1,85 @@ +package launch + +import ( + "path/filepath" + "strings" + "testing" +) + +// itd-2609150819432059 AC2: a non-plugin kind's lockstep check reads the primary +// from version-location.json and every declared file, refuses a declared path it +// cannot read, and reads no plugin manifest. + +func declaredLockstepRepo(t *testing.T, primary, secondary string) string { + t.Helper() + root := t.TempDir() + writeFile(t, root, versionLocationRelPath, `{"outcome":"accept","blocked":false,"manifest_path":"version.json","json_pointer":"/version"}`) + writeFile(t, root, "version.json", primary) + writeFile(t, root, "app/meta.json", secondary) + return root +} + +func TestDeclaredLockstepDevPassesWithEveryKeyAbsentAndReadsNoPluginManifest(t *testing.T) { + root := declaredLockstepRepo(t, `{"name":"app"}`, `{"release":{}}`) + // No .claude-plugin/ exists at all: a check that read the marketplace + // manifest would report it unreadable. + res := CheckDeclaredLockstep(TreeDev, root, filepath.Join(root, versionLocationRelPath), + []LockstepFile{{Path: "app/meta.json", Pointer: "/release/version"}}) + if !res.OK || res.ExitCode != 0 { + t.Fatalf("result %+v, want OK", res) + } + if strings.Contains(res.Detail, "marketplace") { + t.Errorf("the declared check mentions the plugin manifest: %+v", res) + } +} + +func TestDeclaredLockstepDevReportsAPresentVersionKeyAsDrift(t *testing.T) { + // The secondary names no pointer of its own, so it is read at the primary's. + root := declaredLockstepRepo(t, `{"name":"app"}`, `{"version":"1.2.3"}`) + res := CheckDeclaredLockstep(TreeDev, root, filepath.Join(root, versionLocationRelPath), + []LockstepFile{{Path: "app/meta.json"}}) + if res.OK || res.ExitCode != 1 || len(res.Drifts) != 1 || !strings.Contains(res.Drifts[0], "app/meta.json/version") { + t.Fatalf("result %+v, want one dev drift at app/meta.json/version", res) + } +} + +func TestDeclaredLockstepRefusesADeclaredPathItCannotRead(t *testing.T) { + root := declaredLockstepRepo(t, `{"name":"app"}`, `{}`) + res := CheckDeclaredLockstep(TreeDev, root, filepath.Join(root, versionLocationRelPath), + []LockstepFile{{Path: "app/meta.json"}, {Path: "missing/info.json"}}) + if !res.Unreadable || res.ExitCode != 2 || !strings.Contains(res.Detail, "missing/info.json") { + t.Fatalf("result %+v, want unreadable naming missing/info.json", res) + } + if strings.Contains(res.Detail, root) { + t.Errorf("the detail carries an absolute path: %s", res.Detail) + } +} + +func TestDeclaredLockstepPublicRequiresEverySecondaryToAgree(t *testing.T) { + root := declaredLockstepRepo(t, `{"version":"1.2.3"}`, `{"release":{"version":"1.2.0"}}`) + vl := filepath.Join(root, versionLocationRelPath) + files := []LockstepFile{{Path: "app/meta.json", Pointer: "/release/version"}} + res := CheckDeclaredLockstep(TreePublic, root, vl, files) + if res.OK || len(res.Drifts) != 1 || !strings.Contains(res.Drifts[0], `expected "1.2.3"`) { + t.Fatalf("result %+v, want one public drift naming the primary's version", res) + } + writeFile(t, root, "app/meta.json", `{"release":{"version":"1.2.3"}}`) + if res := CheckDeclaredLockstep(TreePublic, root, vl, files); !res.OK { + t.Fatalf("agreeing secondaries: %+v", res) + } +} + +// A non-plugin kind that declares no lockstep list and has no version-location +// contract holds nothing in lockstep, and the check says so rather than refusing. +func TestDeclaredLockstepWithNothingDeclaredIsAnHonestPass(t *testing.T) { + root := t.TempDir() + res := CheckDeclaredLockstep(TreeDev, root, filepath.Join(root, versionLocationRelPath), nil) + if !res.OK || !strings.Contains(res.Detail, "nothing is held in lockstep") { + t.Fatalf("result %+v, want an OK naming that nothing is held in lockstep", res) + } + // A declared list with no primary to agree with is a contract nobody can check. + res = CheckDeclaredLockstep(TreeDev, root, filepath.Join(root, versionLocationRelPath), []LockstepFile{{Path: "a.json"}}) + if !res.Unreadable || !strings.Contains(res.Detail, "version-location.json") { + t.Fatalf("result %+v, want unreadable naming version-location.json", res) + } +} diff --git a/internal/core/launch/parity_test.go b/internal/core/launch/parity_test.go index 20da4bcae..94a59892c 100644 --- a/internal/core/launch/parity_test.go +++ b/internal/core/launch/parity_test.go @@ -22,6 +22,7 @@ func parityRepo(t *testing.T) *gittest.Repo { t.Helper() repo := gittest.NewRepo(t) root := repo.Root() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "commands", "README.md"]}`) writeLockstepTree(t, root, "", "", "") writeFile(t, root, "README.md", "readme at the tag\n") @@ -165,6 +166,7 @@ func TestParityAgainstATagThatShippedNoPayloadIsAllAdded(t *testing.T) { writeFile(t, root, "README.md", "before any payload\n") repo.Commit("no payload yet") repo.Git("tag", "v0.1.0") + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "README.md"]}`) writeLockstepTree(t, root, "", "", "") repo.Commit("a payload") diff --git a/internal/core/launch/render.go b/internal/core/launch/render.go index 37231aecb..d55cc4ae0 100644 --- a/internal/core/launch/render.go +++ b/internal/core/launch/render.go @@ -276,6 +276,18 @@ func PrecheckPayload(repoRoot, dest string, opts PrecheckOptions) (PayloadPreche // The contract says WHERE the version goes. It is read from the source tree // (it is a decision artefact, never shipped) and it is the only thing that // tells the render which manifest and pointer adr-19 selected. + // The render stages a plugin payload. A repository that declares another + // kind has none to stage, and a present declaration is held to the one + // reader, so an unknown kind refuses here before anything is read. + art, err := LoadArtefactOrPlugin(pre.Root) + if err != nil { + return pre, err + } + if !art.IsPlugin() { + return pre, fmt.Errorf("%w: the declared artefact kind is %s (%s), which ships no plugin payload to stage", + ErrNotAPlugin, art.Kind, ArtefactRelPath) + } + pre.VersionLocationPath = filepath.Join(pre.Root, versionLocationRelPath) decision, err := loadJSON(pre.VersionLocationPath) if err != nil { diff --git a/internal/core/launch/scan_coverage_test.go b/internal/core/launch/scan_coverage_test.go index c17003792..13d32b04b 100644 --- a/internal/core/launch/scan_coverage_test.go +++ b/internal/core/launch/scan_coverage_test.go @@ -14,6 +14,7 @@ import ( // old skip-by-extension shipped the raw bytes unscanned. func TestSvgPayloadSecretRefuses(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["docs"]}`) // FAKE token shape only: ghp_ + 36 chars, matching \bghp_[A-Za-z0-9]{36,}. token := "ghp_" + strings.Repeat("a", 36) @@ -49,6 +50,7 @@ func TestSvgPayloadSecretRefuses(t *testing.T) { // closed on it rather than let "some other file scanned" count as coverage. func TestUnscannedPayloadRefuses(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["commands"]}`) // A leading NUL makes an otherwise-.md file read as binary → Unscanned. writeFile(t, root, "commands/clean.md", "wholly clean documentation\n") @@ -94,6 +96,7 @@ func TestUnscannedPayloadRefuses(t *testing.T) { // filename-keyed skip shipped the raw bytes with zero findings. func TestBinaryPayloadSecretRefuses(t *testing.T) { root := t.TempDir() + writeFile(t, root, ArtefactRelPath, `{"kind": "plugin"}`) writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": ["docs"]}`) writeFile(t, root, "docs/README.md", "clean documentation\n") // FAKE token shape only, built at runtime: ghp_ + 36 chars. diff --git a/internal/core/launch/ship.go b/internal/core/launch/ship.go index 47e485f29..d75ce4947 100644 --- a/internal/core/launch/ship.go +++ b/internal/core/launch/ship.go @@ -2,7 +2,6 @@ package launch import ( "errors" - "path/filepath" "github.com/intentdriven/abcd/internal/adapter/scanner" ) @@ -60,12 +59,18 @@ var ErrShipBlocked = errors.New("ship blocked by a launch gate") func Ship(req ShipRequest) (ShipReport, error) { var report ShipReport - bundle, err := ResolveBundle(req.RepoRoot, nil) + // The declaration chooses the reads below, as it does for DryRun; its + // absence is the plugin shape this verdict has always assumed. + art, err := LoadArtefactOrPlugin(req.RepoRoot) + if err != nil { + return ShipReport{}, err // preflight fault + } + bundle, _, err := kindBundle(req.RepoRoot, art) if err != nil { return ShipReport{}, err // preflight fault } report.Bundle = bundle - policy, err := LoadGatePolicy(req.RepoRoot) + policy, err := kindGatePolicy(req.RepoRoot, art) if err != nil { return ShipReport{}, err // preflight fault } @@ -76,8 +81,7 @@ func Ship(req ShipRequest) (ShipReport, error) { // The source tree is checked under the DEV polarity, for the reason DryRun // states: adr-19 keeps the version out of the committed manifests, and the // public polarity is proved over the rendered payload instead. - vlPath := filepath.Join(req.RepoRoot, versionLocationRelPath) - lockstep := CheckLockstep(TreeDev, req.RepoRoot, vlPath) + lockstep := kindLockstep(TreeDev, req.RepoRoot, art) report.Lockstep = lockstep report.Version = req.Version @@ -85,14 +89,17 @@ func Ship(req ShipRequest) (ShipReport, error) { RepoRoot: req.RepoRoot, Version: req.Version, ExistingTags: req.ExistingTags, }) - report.Smoke = SmokeLight(NewBundleTree(bundle)) + report.Smoke = SmokeReport{Tier: SmokeTierLight, OK: true} + if art.IsPlugin() { + report.Smoke = SmokeLight(NewBundleTree(bundle)) + } dirty := DirtyRefuse if req.AllowDirty { dirty = DirtyAllow } suite := runGateSuite(suiteRequest{ RepoRoot: req.RepoRoot, Bundle: bundle, Dirty: dirty, - DocAudit: req.DocAudit, Policy: policy, + DocAudit: req.DocAudit, Policy: policy, Kind: art.Kind, }) report.Gates = suite.Gates report.Warnings = suite.Warnings diff --git a/internal/gitutil/archive.go b/internal/gitutil/archive.go new file mode 100644 index 000000000..f458574bf --- /dev/null +++ b/internal/gitutil/archive.go @@ -0,0 +1,91 @@ +package gitutil + +import ( + "errors" + "path" + "strings" +) + +// ArchiveEntry is one file `git archive` would write for a revision: its +// repo-relative path and its git mode (100644, 100755 or 120000 for a link). +type ArchiveEntry struct { + Path string + Mode string +} + +// ArchiveTree lists the files an archive of rev would carry — the release tag's +// view of the tree — without running `git archive` itself. `git archive` applies +// the repository's filter drivers, and a checkout's own .git/config is fully +// trusted by git, so the listing is built from the two commands that run no +// configured program: `ls-tree` for the committed files, and `check-attr` for the +// export-ignore attribute archive honours, asked of each file and of every +// directory above it (an ignored directory drops everything beneath it). +// Submodules are skipped: archive carries no submodule content. +// +// Attributes are read from the index (--cached), the committed view, so an +// uncommitted .gitattributes edit does not change the answer. An error is a tree +// git could not list — no commit at rev, not a repository, git absent — and is +// never reported as an empty tree. +func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { + out, err := isolatedGit(root, "ls-tree", "-r", "-z", "--full-tree", rev).Output() + if err != nil { + return nil, withStderr(err) + } + var entries []ArchiveEntry + for _, rec := range strings.Split(string(out), "\x00") { + if rec == "" { + continue + } + meta, p, ok := strings.Cut(rec, "\t") + fields := strings.Fields(meta) + if !ok || len(fields) != 3 { + return nil, errors.New("git ls-tree returned a record it does not document: " + rec) + } + if fields[1] != "blob" { + continue // a submodule (commit) carries no content into an archive + } + entries = append(entries, ArchiveEntry{Path: p, Mode: fields[0]}) + } + if len(entries) == 0 { + return nil, nil + } + + candidates := map[string]struct{}{} + for _, e := range entries { + for p := e.Path; p != "." && p != "/" && p != ""; p = path.Dir(p) { + candidates[p] = struct{}{} + } + } + list := make([]string, 0, len(candidates)) + for p := range candidates { + list = append(list, p) + } + cmd := isolatedGit(root, "check-attr", "--cached", "-z", "--stdin", "export-ignore") + cmd.Stdin = strings.NewReader(strings.Join(list, "\x00") + "\x00") + attrs, err := cmd.Output() + if err != nil { + return nil, withStderr(err) + } + ignored := map[string]struct{}{} + fields := strings.Split(string(attrs), "\x00") + // -z emits three fields per record: path, attribute, value. + for i := 0; i+2 < len(fields); i += 3 { + if fields[i+2] == "set" { + ignored[fields[i]] = struct{}{} + } + } + kept := entries[:0] + for _, e := range entries { + drop := false + for p := e.Path; p != "." && p != "/" && p != ""; p = path.Dir(p) { + if _, ok := ignored[p]; ok { + drop = true + break + } + } + if !drop { + kept = append(kept, e) + } + } + return kept, nil +} diff --git a/internal/surface/cli/archive.go b/internal/surface/cli/archive.go index 6b77f5fe4..3b1f004cb 100644 --- a/internal/surface/cli/archive.go +++ b/internal/surface/cli/archive.go @@ -73,6 +73,14 @@ func newLaunchArchiveCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + art, err := launchArtefact("abcd launch archive", cwd) + if err != nil { + return err + } + if !art.IsPlugin() { + return &exitError{Code: 2, Msg: "abcd launch archive: the declared artefact kind is " + string(art.Kind) + + " (" + launch.ArtefactRelPath + "), which ships no plugin archive (nothing was written)"} + } if outDir == "" { return &exitError{Code: 2, Msg: "abcd launch archive: --out names no directory"} } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index e3ba1cad7..84b67dc28 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -337,6 +337,8 @@ func NewRootCommand() *cobra.Command { rep.ReportPath, rep.ReportError = writePreflight(cwd, rep.PreflightReport(time.Now())) return render(cmd.OutOrStdout(), asJSON, rep, func(w io.Writer) { fmt.Fprintf(w, "abcd launch (dry-run) — version %s\n", rep.Version) + fmt.Fprintf(w, " artefact kind: %s\n", rep.Kind) + fmt.Fprintf(w, " scanned tree: %s\n", rep.ScannedTree) fmt.Fprintf(w, " files bundled: %d\n", len(rep.Bundle.Included)) fmt.Fprintf(w, " scan hardfails: %d\n", rep.Scan.HardFails) for _, g := range rep.Gates { diff --git a/internal/surface/cli/launch_nopayload_test.go b/internal/surface/cli/launch_nopayload_test.go index 7dbe6845f..b629ff6d3 100644 --- a/internal/surface/cli/launch_nopayload_test.go +++ b/internal/surface/cli/launch_nopayload_test.go @@ -7,16 +7,41 @@ import ( "testing" ) -// TestLaunchDryRunInANonPayloadRepoNamesTheReleasePath is iss-2608270559313719: -// a repository with no launch payload is told which release path it does have, -// not handed a missing-file error. -func TestLaunchDryRunInANonPayloadRepoNamesTheReleasePath(t *testing.T) { +// TestLaunchDryRunWithNoDeclarationNamesItsHome is itd-2609150819432059 AC1: a +// managed repository that has not declared its artefact kind is told where the +// declaration lives and which kinds it accepts — never a missing-file error — +// and nothing is written into it. +func TestLaunchDryRunWithNoDeclarationNamesItsHome(t *testing.T) { r := shipFixture(t) out, err := shipIn(t, r, "launch", "--dry-run") if code := exitCodeOf(err); code != 1 { t.Fatalf("exit = %d, want 1\n%s", code, out) } - for _, want := range []string{"declares no launch payload", "launch scaffold", "CHANGELOG", "auto-release"} { + for _, want := range []string{".abcd/config/artefact.json", "plugin, binary, application", "abcd ahoy install"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q:\n%v", want, err) + } + } + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no such file") { + t.Errorf("the refusal reads as a missing-file error:\n%v", err) + } + if _, statErr := os.Stat(filepath.Join(r.Root(), ".abcd", ".work.local")); statErr == nil { + t.Error("a repository with no declaration must not have a report written into it") + } +} + +// TestLaunchDryRunInANonPayloadPluginNamesTheReleasePath is iss-2608270559313719: +// a repository declaring a plugin with no launch payload is told which release +// path it does have, not handed a missing-file error. +func TestLaunchDryRunInANonPayloadPluginNamesTheReleasePath(t *testing.T) { + r := shipFixture(t) + r.Write(".abcd/config/artefact.json", `{"kind": "plugin"}`+"\n") + r.Commit("declare the plugin kind") + out, err := shipIn(t, r, "launch", "--dry-run") + if code := exitCodeOf(err); code != 1 { + t.Fatalf("exit = %d, want 1\n%s", code, out) + } + for _, want := range []string{"declares kind plugin", "no launch payload", "launch scaffold", "CHANGELOG", "auto-release", "binary or application"} { if !strings.Contains(err.Error(), want) { t.Errorf("the refusal does not name %q:\n%v", want, err) } @@ -25,3 +50,29 @@ func TestLaunchDryRunInANonPayloadRepoNamesTheReleasePath(t *testing.T) { t.Error("a repository with no launch payload must not have a report written into it") } } + +// TestLaunchDryRunForADeclaredBinarySaysWhichTreeItScanned is AC8 at the front +// door: a declared non-plugin kind previews with no payload config, and the +// report says which tree was scanned. +func TestLaunchDryRunForADeclaredBinarySaysWhichTreeItScanned(t *testing.T) { + r := shipFixture(t) + r.Remove(".claude-plugin/plugin.json") + r.Remove(".claude-plugin/marketplace.json") + r.Write(".abcd/config/artefact.json", `{"kind": "binary"}`+"\n") + r.Write("main.go", "package main\n\nfunc main() {}\n") + r.Commit("a managed binary") + out, err := shipIn(t, r, "launch", "--dry-run") + if err != nil { + t.Fatalf("a declared binary must preview: %v\n%s", err, out) + } + for _, want := range []string{"artefact kind: binary", "scanned tree: the tree the release tag would archive"} { + if !strings.Contains(string(out), want) { + t.Errorf("the preview does not say %q:\n%s", want, out) + } + } + for _, not := range []string{"launch-payload", "marketplace.json", "plugin.json"} { + if strings.Contains(string(out), "would refuse on: "+not) { + t.Errorf("a plugin-only read refused a binary (%s):\n%s", not, out) + } + } +} diff --git a/internal/surface/cli/launch_preflight.go b/internal/surface/cli/launch_preflight.go index 994bf2ebc..2366e3cb0 100644 --- a/internal/surface/cli/launch_preflight.go +++ b/internal/surface/cli/launch_preflight.go @@ -9,15 +9,18 @@ import ( "github.com/intentdriven/abcd/internal/core/lint" ) -// noLaunchPayloadGuidance is what a repository with no launch payload is told -// (iss-2608270559313719). It is not misconfigured: a repository that ships no -// plugin bundle has no include config, and its releases go through the -// changelog-driven path instead, which the refusal names. -const noLaunchPayloadGuidance = "this repository declares no launch payload (.abcd/config/launch-payload.json), " + - "so there is no plugin bundle to preview, gate or stage. A repository without one releases through the " + - "changelog-driven path: `abcd launch scaffold` installs the release workflows, `abcd launch ship` derives the " + - "version and writes the dated CHANGELOG heading, and the auto-release workflow tags that commit on merge. " + - "A repository that does ship a plugin declares its payload in that file" +// noLaunchPayloadGuidance is what a repository that declares kind plugin but +// no launch payload is told (iss-2608270559313719). A plugin's bundle is its +// include set, so there is nothing to preview, gate or stage until it declares +// one; a repository that ships something other than a plugin says so in its +// artefact declaration instead, and its preview scans the tree the release tag +// would archive (itd-2609150819432059). +const noLaunchPayloadGuidance = "this repository declares kind plugin (.abcd/config/artefact.json) but no launch payload " + + "(.abcd/config/launch-payload.json), so there is no plugin bundle to preview, gate or stage. A plugin declares its " + + "payload in that file. A repository that ships no plugin declares its kind as binary or application instead, and " + + "its preview scans the tree the release tag would archive; its releases go through the changelog-driven path: " + + "`abcd launch scaffold` installs the release workflows, `abcd launch ship` derives the version and writes the dated " + + "CHANGELOG heading, and the auto-release workflow tags that commit on merge" // launchPayloadRefusal turns a launch error into what the operator reads: the // release-path guidance for a repository with no payload, the scrubbed error @@ -29,6 +32,21 @@ func launchPayloadRefusal(err error) string { return scrubPaths(err) } +// launchArtefact reads the artefact declaration a launch verb runs against, +// through the one reader every verb and ahoy share (itd-2609150819432059). A +// declaration that is present and wrong — an unknown kind, a malformed file — +// refuses the verb before it reads or writes anything else. Its absence is the +// plugin shape the verbs that predate it assume; the preview and the scaffold, +// which choose what to read and write by the kind, refuse the absence in the +// core instead. +func launchArtefact(verb, cwd string) (launch.Artefact, error) { + art, err := launch.LoadArtefactOrPlugin(cwd) + if err != nil { + return art, &exitError{Code: 2, Msg: verb + ": " + scrubPaths(err) + " (nothing was written)"} + } + return art, nil +} + // docAuditPreflight measures the documentation audit a launch's // documentation-auditor row reports: the docs-lint engine over the // repository's configured doc roots. It is measured HERE, at the front door, diff --git a/internal/surface/cli/launch_receipts.go b/internal/surface/cli/launch_receipts.go index 420cb0376..55fa79a7d 100644 --- a/internal/surface/cli/launch_receipts.go +++ b/internal/surface/cli/launch_receipts.go @@ -41,6 +41,9 @@ func newLaunchReceiptsCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + if _, err := launchArtefact("abcd launch receipts", cwd); err != nil { + return err + } check, err := lint.CheckReleaseReceipts(cwd) if err != nil { return &exitError{Code: 2, Msg: "abcd launch receipts: " + scrubPaths(err)} diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index dc80db381..216b87868 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -331,6 +331,17 @@ func newLaunchShipCommand(asJSON *bool) *cobra.Command { if err != nil { return err } + // The cut itself — the derivation, the guard, the deferral read — is + // the same for every kind; the declaration only decides whether there + // is a plugin payload to stage. + art, err := launchArtefact("abcd launch ship", cwd) + if err != nil { + return err + } + if payloadDir != "" && !art.IsPlugin() { + return &exitError{Code: 2, Msg: "abcd launch ship: --payload-dir stages a plugin payload, and the declared artefact kind is " + + string(art.Kind) + " (" + launch.ArtefactRelPath + "), which ships none (nothing was written)"} + } // The payload is rendered by the INGEST step, from a version only a // completed cut has. Asking the emit step for one is an operand // error, refused before anything is read or staged. diff --git a/internal/surface/cli/ship_payload_test.go b/internal/surface/cli/ship_payload_test.go index db5c01c10..b5e914eb0 100644 --- a/internal/surface/cli/ship_payload_test.go +++ b/internal/surface/cli/ship_payload_test.go @@ -14,12 +14,14 @@ import ( "github.com/intentdriven/abcd/internal/gittest" ) -// shipRenderableRepo is shipReadyRepo plus the two config artefacts a payload -// render needs: the adr-19 version-location contract (WHERE the version goes) -// and the payload includes (WHAT ships). +// shipRenderableRepo is shipReadyRepo plus the config artefacts a payload +// render needs: the artefact declaration (a plugin), the adr-19 +// version-location contract (WHERE the version goes) and the payload includes +// (WHAT ships). func shipRenderableRepo(t *testing.T) *gittest.Repo { t.Helper() r := shipReadyRepo(t) + r.Write(".abcd/config/artefact.json", `{"kind": "plugin"}`+"\n") r.Write(".abcd/config/version-location.json", `{"manifest_path": ".claude-plugin/plugin.json", "json_pointer": "/version"}`+"\n") r.Write(".abcd/config/launch-payload.json", @@ -334,6 +336,10 @@ func TestLaunchDryRunSanitisesRefusalReasons(t *testing.T) { if err := os.MkdirAll(filepath.Join(repo, ".abcd", "config"), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config", "artefact.json"), + []byte(`{"kind": "plugin"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(repo, ".abcd", "config", "launch-payload.json"), []byte(`{"includes": ["commands"]}`+"\n"), 0o644); err != nil { t.Fatal(err) From ee2ad0f1030adc940c66669d34a9dc3ae5e5f801 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:51:25 +0100 Subject: [PATCH 051/107] fix(launch): scaffold gate plumbing and a named empty build job by kind `launch scaffold` lays what the declared artefact kind needs and refuses without a declaration (itd-2609150819432059, decisions 4-7): - a plugin keeps release.yml, auto-release.yml, the runbook and the reviews-charter check; - any other kind receives abcd-release-gate.yml, the same template rendered as a gate that is only ever called (no tag-push trigger of its own, and a `publish` input a caller that builds itself turns off), the runbook, the charter check, CHANGELOG.md holding only the empty [Unreleased] anchor when it has none, and auto-release.yml calling the gate unless a release workflow of its own is in charge. That workflow is left byte-for-byte; the report names it as kept and prints the job to add to it. Every scaffolded file is drift-checked as before. A managed repository's release workflow no longer builds with a guessed command: it carries gate plumbing and a named empty `build` job whose one step the repository fills, naming dist/ as the output the publish job attaches (decision 6). The verify job's Go leg renders only for a repository with a go.mod. abcd's own rendering is byte-identical (self-scaffold parity). The workflow name the templates call and describe is a substitution, and the end-to-end workflow run gains a binary-gate profile that cuts and publishes a first release through auto-release and the gate. Refs: iss-2608270559310755 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 63 ++++- commands/launch.md | 114 +++++++- docs/reference/cli/commands.md | 2 +- internal/core/launch/scaffold/kind.go | 91 ++++++ internal/core/launch/scaffold/kind_test.go | 265 ++++++++++++++++++ .../launch/scaffold/mergepath_release_test.go | 28 +- internal/core/launch/scaffold/render.go | 31 ++ internal/core/launch/scaffold/scaffold.go | 71 ++++- .../core/launch/scaffold/scaffold_test.go | 12 + .../core/launch/scaffold/substitutions.go | 20 +- .../launch/scaffold/tagorder_workflow_test.go | 68 +++-- .../scaffold/templates/auto-release.yml.tmpl | 56 ++-- .../scaffold/templates/release.yml.tmpl | 144 ++++++++-- .../launch/scaffold/templates/runbook.md.tmpl | 50 +++- internal/core/launch/scaffold/wiring_test.go | 9 +- .../core/site/releasechainsecrets_test.go | 5 +- internal/surface/cli/launch_kind_test.go | 146 ++++++++++ internal/surface/cli/scaffold.go | 28 +- 18 files changed, 1059 insertions(+), 144 deletions(-) create mode 100644 internal/core/launch/scaffold/kind.go create mode 100644 internal/core/launch/scaffold/kind_test.go create mode 100644 internal/surface/cli/launch_kind_test.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 64f189fa7..299a46055 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -14,12 +14,40 @@ And it never ships the design record: the payload is default-deny with the whole The preview always exits 0, because a preview never blocks, and its one write is its pre-flight report in the gitignored local tier. Bare `abcd launch` refuses -with a hint to ask for it. A repository with no launch payload -(`.abcd/config/launch-payload.json`) has nothing to preview, and the preview -says so and names the release path it does have: the scaffolded release +with a hint to ask for it. A repository that declares `kind: plugin` with no +launch payload (`.abcd/config/launch-payload.json`) has nothing to preview, and +the preview says so and names the release path a repository that ships no plugin +has: declaring its kind as `binary` or `application`, the scaffolded release workflows, the dated CHANGELOG heading the cut writes, and the auto-release workflow that tags it. +**Every verb runs against a declared artefact kind** (itd-2609150819432059). A +managed repository says once what it ships, in `.abcd/config/artefact.json`: +`kind` is `plugin`, `binary` or `application`, `lockstep` names the JSON files a +non-plugin kind holds in lockstep with its version-location primary, and `site` +reserves the release-rendered site's opt-in, read and not yet acted on. One +reader in `internal/core/launch` validates the file for every launch verb and for +`ahoy`, whose `artefact.missing` gap writes it — kind `plugin` without a question +for a repository carrying a plugin manifest, otherwise the kind the operator +answers. An unknown kind or a malformed declaration refuses every verb before +anything is written, naming the kind and the accepted set. The preview and the +scaffold choose what to read and write by the kind, so they refuse a repository +that has declared none, naming the file and the kinds and never a missing-file +error; the cut, the archive render and the receipts check read an absent +declaration as the plugin shape they have always assumed. The gate's inputs — the ledger, the anchor tag, +the version location — are kind-independent, so the cut's derivation, its +findings gate and the deferral read run unchanged for every kind; only the reads +that bind a run to a plugin follow the kind. For a kind other than `plugin` the +preview scans the tree the release tag would archive (`git archive`'s view of +`HEAD`, `export-ignore` honoured, links excluded) minus the record namespace, +denied by the same rule a plugin payload is held to, unless it declares an +include set; the report names which tree it scanned. Its lockstep check reads +the primary and every declared file, reads no plugin manifest, and refuses a +declared file it cannot read. The rows that judge a plugin payload — the +installability smoke and its deep tier, hook compliance, the parity diff — report +`not_armed` and name the kind. For a kind that ships no plugin payload, the cut +refuses to stage one and the archive render refuses outright. + > **Phase ownership** ([adr-33](../../decisions/adrs/0033-launch-phase-ownership-tiered.md)): the curated-release cut — packaging with `.abcd/**` excluded plus the secret/PII scan — ships in [Phase 1](../../roadmap/phases/phase-1-ahoy.md). The pre-flight gate suite (itd-65) and the payload parity diff with the deep installability smoke (itd-66) run on the preview and on the cut's render path; the remaining release automation is separately scheduled (itd-70 retention, itd-72 publishing); itd-73 derived versioning ships with the release cut. ## Sub-verbs @@ -117,9 +145,25 @@ version flag at all: the version is derived, never authored ([adr-31](../../decisions/adrs/0031-derived-versioning-from-intents.md)). **The scaffold writes the release machinery into a managed repo that lacks -it**: the two release workflows, the adr-37 release runbook and a reviews-charter -check, wired to the repo's own default branch and Go version and to the check -names its own pull-request CI reports, token-scoped and injection-safe. The check +it**, shaped by the declared artefact kind and refused without one. A plugin +receives the two release workflows, the adr-37 release runbook and a +reviews-charter check. Any other kind receives the gate as a workflow of its own, +`abcd-release-gate.yml`, rendered from the same template, which is only ever +called — never triggered by a tag push, so it cannot race a tag-driven workflow +the repository already runs — plus the runbook and the charter check, a +`CHANGELOG.md` holding only the empty `[Unreleased]` anchor when it has none (an +existing changelog is never opened), and `auto-release.yml` calling the gate +unless a release workflow of its own is in charge. That workflow is left +byte-for-byte; the report names it as left alone and prints the job to add to it +so it calls the gate before its build step, with `publish: false` so the gate +verifies and tags only. Every scaffolded file is drift-checked the same way. A +managed repository's workflow carries gate plumbing and a **named empty build +job**: abcd does not guess how a repository builds (iss-2608270559310755), so the +job holds one step to fill and names `dist/` as its output, and the publish job +attaches whatever is left there. The verify job's Go leg renders only for a +repository with a `go.mod`. Every file is wired to the repo's own default branch +and Go version and to the check names its own pull-request CI reports, +token-scoped and injection-safe. The check names are read from the repo's pull-request and merge-queue workflows — a name only a run knows (a matrix job, an expression-named job, a reusable-workflow call) is omitted rather than guessed, and every name is held to an injection-safe @@ -133,12 +177,13 @@ are regenerated from, proved byte-exact by a test, so a scaffolded repo and this one cannot drift. The scaffolded workflow carries a **rehearsal** that arms the full gate against a simulated changelog roll and publishes nothing, so a green rehearsal is the runbook's precondition for a first real release. A bare repo -with no semantic detector degrades cleanly to the deterministic gates and a -generic build. +with no semantic detector degrades cleanly to the deterministic gates and the +empty build job. It is idempotent and fail-safe: a re-run on current machinery is a no-op (exit 0), a hand-edited file is refused (exit 1) rather than clobbered unless -the caller confirms, and a structural fault exits 2. +the caller confirms, and a structural fault, a missing declaration or an unknown +kind exits 2 with nothing written. **The preview is spelled `dry-run`, and it is a flag, not a sub-verb.** The binary registers no `dry-run` subcommand under launch, and `commands/launch.md` diff --git a/commands/launch.md b/commands/launch.md index b615c8a22..6c38231aa 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -1,6 +1,6 @@ --- name: launch -description: Preview the public launch — the file bundle, the secret/PII scan, and the release gates — in dry-run mode, cut a release by deriving its version and composing its changelog and release page, render and verify the release's pinned plugin archive, run the release job's semantic-receipt gate locally before the merge, and scaffold the changelog-driven release gate into a managed repo. The preview writes only its pre-flight report, to the gitignored local tier; `ship` writes the dated CHANGELOG heading, the RELEASE.md page and the archive pin and never publishes; `archive` writes one zip where it is told and never publishes; `receipts` writes nothing; `scaffold` writes the release workflows and never publishes. +description: Preview the public launch — the file bundle, the secret/PII scan, and the release gates — in dry-run mode, cut a release by deriving its version and composing its changelog and release page, render and verify the release's pinned plugin archive, run the release job's semantic-receipt gate locally before the merge, and scaffold the changelog-driven release gate into a managed repo, each shaped by the artefact kind the repository declares. The preview writes only its pre-flight report, to the gitignored local tier; `ship` writes the dated CHANGELOG heading, the RELEASE.md page and the archive pin and never publishes; `archive` writes one zip where it is told and never publishes; `receipts` writes nothing; `scaffold` writes the release workflows and never publishes. argument-hint: "[--dry-run [--deep-smoke] [--baseline <vX.Y.Z>] [--fetch-baseline]] | ship [--changelog-json <path>] [--payload-dir <dir>] [--allow-dirty] [--fetch-baseline] | archive --out <dir> [--tag <vX.Y.Z>] [--verify] [--repository <owner/name>] | receipts | scaffold [--confirm]" --- @@ -24,6 +24,40 @@ it reads the dated heading `ship` writes. A third verb, **archive**, is the rele workflow's half of the pin: it renders the plugin archive from a commit and proves the committed pin names exactly that archive. +## The artefact declaration + +Every verb runs against what the repository says it ships, declared once in +`.abcd/config/artefact.json`: + +```json +{ + "kind": "binary", + "lockstep": ["package.json", {"path": "app/meta.json", "json_pointer": "/release/version"}], + "site": false +} +``` + +- `kind` — `plugin` (a harness plugin: the payload, manifests and catalog the + gates already judge), `binary` (a built program, a Go binary in the first cut) + or `application` (an application with its own build and publish steps). Any + other value is refused by every verb, naming the kind and the accepted set, + before anything is written. +- `lockstep` — for a kind other than `plugin`, the JSON files held in lockstep + with the version-location primary: each carries the version at its own + `json_pointer`, or at the primary's when it names none. A plugin's lockstep is + the pinned manifest table, so a list declared for one is refused. +- `site` — the release-rendered site opt-in, read and validated but not yet + acted on. + +`ahoy install` writes the declaration: a repository carrying +`.claude-plugin/plugin.json` adopts `kind: plugin` without being asked, and any +other is asked its kind. The preview and the scaffold choose what to read and +write by the kind, so they refuse a repository that has declared none, naming the +file and the kinds; `ship`, `archive` and `receipts` read an absent declaration as +the plugin shape and hold a present one to the same reader. The cut itself — the +derived version, the changelog, the findings gate and its deferral read — is the +same for every kind. + ## Release day: what a human actually does The rest of this page describes the verbs. This section describes the **day** — @@ -183,9 +217,19 @@ Run: Then summarise the JSON for the user: - `version` — the version the release would carry. +- `kind` — the declared artefact kind the preview ran against. +- `scanned_tree` — which tree the bundle and its scan cover: the plugin payload + (the include set in `.abcd/config/launch-payload.json`), or, for a kind other + than `plugin` that declares no include set, the tree the release tag would + archive — `git archive`'s view of `HEAD`, `export-ignore` honoured — minus the + record namespace, denied by the same rule a plugin payload is held to. Links in + that tree are excluded (`symlink`): an archive carries a link as the path it + names, not as content. - `bundle.files` — the files the bundle would include (an array; report its length as the count). - `scan.hard_fails` — secret/PII findings that would block the release. -- `smoke.ok` — whether the payload would install: both plugin manifests parse, +- `smoke.ok` — whether the payload would install (a plugin only; for another + kind the `installability-smoke` row is `not_armed`, as are `hook-compliance`, + the deep tier and the parity diff, each naming the declared kind): both plugin manifests parse, the marketplace source resolves, and every declared command, agent, skill and hook path is carried. `smoke.findings` names any path that is not. - `deep_smoke` — present only when the preview was run with `--deep-smoke`: the @@ -249,7 +293,11 @@ Then summarise the JSON for the user: never publishes, so it is not a verdict on the release. Read `gates` and `would_refuse_on` for that. - `lockstep` and `retention` — the manifest-lockstep result and the release - retention plan. Both feed `would_refuse_on`, so a lockstep drift or a + retention plan. For a kind other than `plugin` the lockstep check reads the + primary from `.abcd/config/version-location.json` and every declared lockstep + file, reads no plugin manifest, and refuses a declared file it cannot read by + name; a kind with neither a contract nor a list holds nothing in lockstep, and + the result says so. Both feed `would_refuse_on`, so a lockstep drift or a retention refusal is invisible to anyone who reads only the gate list. - `would_refuse_on` — if non-empty, every finding a cut would refuse on, from every gate at once, so the user can fix them in one pass. A dirty working tree @@ -264,10 +312,14 @@ Then summarise the JSON for the user: This is preview-only: publishing is not driven from this command. -A repository with no `.abcd/config/launch-payload.json` has no plugin payload to -preview. The preview says so and names the release path such a repository has: -`launch scaffold`, `launch ship` writing the dated CHANGELOG heading, and the -auto-release workflow. Relay that; it is not a misconfiguration. +A repository with no `.abcd/config/artefact.json` is refused, naming the file +as the declaration's home and the kinds it accepts; relay it and point the user +at `ahoy install`. A repository that declares `kind: plugin` with no +`.abcd/config/launch-payload.json` has no plugin payload to preview: the preview +says so and names the release path a repository that ships no plugin has — +declaring its kind as `binary` or `application`, `launch scaffold`, `launch ship` +writing the dated CHANGELOG heading, and the auto-release workflow. Relay that; +it is not a misconfiguration. ## Ship — the release cut @@ -440,6 +492,9 @@ renders the archive again from the tagged commit and publishes nothing unless th digests agree. These three files — `CHANGELOG.md`, the catalog and the snapshot — are the release-content commit. +`--payload-dir` stages a plugin payload, so a repository that declares another +artefact kind has it refused before anything is read or written (exit 2). + Without that declaration the catalog is left untouched, and the report says so (`archive: not pinned — …`, or `archive_unpinned` in `--json`). The contract alone is not enough: it says where the version lives, not that a release uploads an @@ -720,6 +775,9 @@ both addresses, and the archive is removed so no later step can publish it; `--repository` that is not `owner/name`, an unusable `--out`, a render refusal), with nothing left behind. +A repository that declares an artefact kind other than `plugin` ships no plugin +archive, and `archive` refuses there (exit 2) before anything is rendered. + Relay `archive.name`, `archive.sha256`, `url`, `pin` and `repository`. Between releases, `main` pins the last release's archive, which its moved-on tree no longer reproduces, so `--verify` there is expected to refuse: it proves a release commit, not a branch @@ -735,10 +793,15 @@ already has the machinery). It **never publishes**. "${CLAUDE_PLUGIN_ROOT}/abcd" launch scaffold --json ``` -It writes four files, wired to the repo's own default branch and Go version and -to the check names its own pull-request CI reports: +What it writes follows the declared artefact kind (see *The artefact +declaration* above); a repository that has declared none, or a kind abcd does not +know, is refused with exit 2 before anything is written. Every file is wired to +the repo's own default branch and Go version and to the check names its own +pull-request CI reports. `kind` in the report names the kind it followed. -- `.github/workflows/release.yml` — verify → build → publish. With semantic +For **`kind: plugin`**, four files: + +- `.github/workflows/release.yml` — verify → tag → build → publish. With semantic gates configured, `verify` arms the receipt gate against the reviewed **content** commit it derives from the receipts directory of the released tree, so the first public release cannot hit the receipt-vs-tag @@ -755,6 +818,34 @@ to the check names its own pull-request CI reports: dated review directories keep their shape, and the sha-keyed receipt directories are exempt. The scaffolded `verify` job runs it. +The `build` job in a managed repository's workflow is **empty by design**: abcd +lays the gate plumbing and does not guess how the repository builds. It carries +one step to fill (or to replace with a call to the repository's own build) and +names `dist/` as its output: the publish job attaches every file left there to +the GitHub Release, and with none the Release carries its generated notes alone. +The verify job's Go leg (setup-go, gofmt, build, vet, test, race) is written only +for a repository with a `go.mod`. + +For **`kind: binary`** or **`kind: application`**: + +- `.github/workflows/abcd-release-gate.yml` — the gate as a workflow of its own: + the same verify, tag, receipt and publish plumbing with the same empty `build` + job, rendered from the one template. It is only ever *called* — never triggered + by a tag push — so it cannot race a tag-driven workflow the repository already + runs, and a caller that builds and publishes itself passes `publish: false`. +- `.github/workflows/auto-release.yml`, calling the gate — only when the + repository has no release workflow of its own. +- `CHANGELOG.md` holding only the empty `## [Unreleased]` anchor, when the + repository has none. History before adoption is not represented; an existing + changelog is the release record and is reported `kept`, never opened. +- The runbook and the reviews charter, as above. + +A repository with its own `.github/workflows/release.yml` (or `.yaml`) keeps it +**byte-for-byte**: the report lists it as `kept`, "left alone", and prints the job +to add to it so it calls the gate before its build step (`call_stanza` in +`--json`). Relay that stanza verbatim; the scaffold never edits the repository's +own workflow. + The check names come from the repo's workflows triggered by `pull_request` or `merge_group`; a name only a run knows (a matrix job, an expression-named job, a reusable-workflow call) is left out rather than guessed. Relay `ci_checks` and tell @@ -783,7 +874,8 @@ It is idempotent and fail-safe. Exit codes gate the flow: per-file disposition. - **1** — a file exists and **differs** (hand-edited or stale); the report names it and **nothing was written**. Relay it; re-run with `--confirm` to overwrite. -- **2** — a structural fault (the repository or a template could not be read). +- **2** — a structural fault (the repository or a template could not be read), or + no artefact declaration, or a kind abcd does not know. Nothing was written. A refusal is a result to relay, not a crash. Never hand-edit the workflows to work around it: re-run with `--confirm` when the operator intends to replace the drift. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7e71ff9fa..1ae7356ca 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1178,7 +1178,7 @@ Run the release job's semantic-receipt gate locally, before the merge (exit 1 wh #### `abcd launch scaffold` -Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook, reviews charter) into this repo +Scaffold the changelog-driven release gate into this repo, shaped by its declared artefact kind **Usage:** `abcd launch scaffold [--confirm] [flags]` diff --git a/internal/core/launch/scaffold/kind.go b/internal/core/launch/scaffold/kind.go new file mode 100644 index 000000000..a9d529b61 --- /dev/null +++ b/internal/core/launch/scaffold/kind.go @@ -0,0 +1,91 @@ +package scaffold + +// kind.go — the kind-shaped scaffold (itd-2609150819432059, decisions 4–7). +// +// A plugin keeps the shipped set: release.yml, auto-release.yml, the runbook and +// the reviews-charter check. Any other declared kind receives the gate workflow +// as a file of its own — the release template rendered as gate plumbing plus a +// named empty build job — the runbook and the charter check, the empty +// [Unreleased] anchor when it has no changelog, and auto-release.yml only when +// no release workflow of its own is already in charge. An existing release +// workflow is never opened; the report names it and prints what to add to it. + +import ( + "os" + "path/filepath" +) + +// The non-plugin kind's files. +const ( + // GateWorkflowName is the gate workflow's file name. + GateWorkflowName = "abcd-release-gate.yml" + // GateWorkflowPath is where the gate workflow is written. + GateWorkflowPath = ".github/workflows/" + GateWorkflowName + // ChangelogPath is the release record the changelog-driven path reads. + ChangelogPath = "CHANGELOG.md" + // ChangelogAnchor is the changelog a repository with none receives: the + // empty [Unreleased] anchor and nothing else (decision 7). History before + // adoption is not represented. + ChangelogAnchor = "# Changelog\n\n## [Unreleased]\n" +) + +// ownReleaseWorkflows are the paths a repository's own release workflow is +// found at. A non-plugin kind is never written one of them, so a file there is +// the repository's, not abcd's. +var ownReleaseWorkflows = []string{".github/workflows/release.yml", ".github/workflows/release.yaml"} + +// gateCallStanza is the job a repository's own release workflow adds to call +// the gate before it builds. The gate's tag and release jobs declare +// contents: write, and a called workflow's jobs may not ask for more than the +// calling job grants, so the stanza grants it; with create_tag and publish +// false those jobs are skipped and write nothing. +const gateCallStanza = `jobs: + abcd-release-gate: + uses: ./.github/workflows/` + GateWorkflowName + ` + with: + tag: ${{ github.ref_name }} + publish: false + permissions: + contents: write + # ...and on the job that builds and publishes: + # needs: abcd-release-gate +` + +// GateSubstitutions is the fact set a non-plugin kind's gate renders with: the +// bare profile rendered as the gate workflow. own names the repository's own +// release workflow when it has one, which the runbook then describes as the +// gate's caller. +func GateSubstitutions(defaultBranch, own string) Substitutions { + subs := BareSubstitutions(defaultBranch) + subs.Gate = true + subs.ReleaseWorkflow = GateWorkflowName + subs.OwnReleaseWorkflow = own + if own != "" { + subs.CallStanza = gateCallStanza + } + return subs +} + +// findOwnReleaseWorkflow returns the repository's own release workflow, +// repo-relative, or "" when it has none. Lstat, so a link there counts as +// present: it is never opened, only left alone. +func findOwnReleaseWorkflow(repoRoot string) string { + for _, rel := range ownReleaseWorkflows { + if _, err := os.Lstat(filepath.Join(repoRoot, filepath.FromSlash(rel))); err == nil { + return rel + } + } + return "" +} + +// isGoModule reports whether the repository carries a go.mod. +func isGoModule(repoRoot string) bool { + info, err := os.Lstat(filepath.Join(repoRoot, "go.mod")) + return err == nil && info.Mode().IsRegular() +} + +// plannedFile is one file a scaffold run writes or leaves current. +type plannedFile struct { + rel string + data []byte +} diff --git a/internal/core/launch/scaffold/kind_test.go b/internal/core/launch/scaffold/kind_test.go new file mode 100644 index 000000000..791d23df0 --- /dev/null +++ b/internal/core/launch/scaffold/kind_test.go @@ -0,0 +1,265 @@ +package scaffold + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/launch" +) + +// itd-2609150819432059: the scaffold lays what the declared artefact kind needs +// and refuses to guess the rest. + +// kindRepo is a Go module with a git checkout and the given declaration ("" for +// none). +func kindRepo(t *testing.T, kind string) string { + t.Helper() + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n") + gitInit(t, dir, "main") + if kind != "" { + mustWrite(t, filepath.Join(dir, filepath.FromSlash(launch.ArtefactRelPath)), `{"kind": "`+kind+`"}`+"\n") + } + return dir +} + +func statusOf(rep Report, path string) (FileOutcome, bool) { + for _, f := range rep.Files { + if f.Path == path { + return f, true + } + } + return FileOutcome{}, false +} + +func treeFiles(t *testing.T, dir string) []string { + t.Helper() + var files []string + _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && d.Name() == ".git" { + return filepath.SkipDir + } + if !d.IsDir() { + rel, _ := filepath.Rel(dir, p) + files = append(files, filepath.ToSlash(rel)) + } + return nil + }) + return files +} + +// The scaffold chooses its file set by the declared kind, so a repository that +// has declared none is refused — naming the declaration's home — before a +// single file is written. +func TestScaffoldWithNoDeclarationRefusesAndWritesNothing(t *testing.T) { + dir := kindRepo(t, "") + before := treeFiles(t, dir) + _, err := Scaffold(Request{RepoRoot: dir}) + if !errors.Is(err, launch.ErrNoArtefact) { + t.Fatalf("err = %v, want the no-declaration refusal", err) + } + if after := treeFiles(t, dir); len(after) != len(before) { + t.Errorf("a refused scaffold wrote files: %v -> %v", before, after) + } +} + +// AC9 for the scaffold: an unknown kind refuses and writes nothing. +func TestScaffoldRefusesAnUnknownKind(t *testing.T) { + dir := kindRepo(t, "wheel") + before := treeFiles(t, dir) + _, err := Scaffold(Request{RepoRoot: dir}) + if err == nil || !strings.Contains(err.Error(), `"wheel"`) || !strings.Contains(err.Error(), "plugin, binary, application") { + t.Fatalf("err = %v, want a refusal naming the kind and the accepted set", err) + } + if after := treeFiles(t, dir); len(after) != len(before) { + t.Errorf("a refused scaffold wrote files: %v -> %v", before, after) + } +} + +// A plugin keeps the shipped set: release.yml, auto-release.yml, the runbook +// and the reviews-charter check, and no gate workflow of its own. +func TestScaffoldForAPluginKeepsTheShippedSet(t *testing.T) { + dir := kindRepo(t, "plugin") + rep, err := Scaffold(Request{RepoRoot: dir}) + if err != nil { + t.Fatal(err) + } + if rep.Kind != launch.KindPlugin || rep.Wrote != 4 { + t.Fatalf("report %+v, want kind plugin and four files written", rep) + } + if _, ok := statusOf(rep, GateWorkflowPath); ok { + t.Error("a plugin received the non-plugin gate workflow") + } +} + +// AC3: a declared non-plugin kind with no CHANGELOG.md gets the empty +// [Unreleased] anchor, the gate workflow as a file of its own with a named empty +// build job, and a report naming every file written. +func TestScaffoldForABinaryLaysTheChangelogAndTheGateWorkflow(t *testing.T) { + dir := kindRepo(t, "binary") + rep, err := Scaffold(Request{RepoRoot: dir}) + if err != nil { + t.Fatalf("scaffold: %v", err) + } + if rep.Kind != launch.KindBinary { + t.Errorf("report kind %q, want binary", rep.Kind) + } + if got := readFile(t, filepath.Join(dir, ChangelogPath)); got != ChangelogAnchor { + t.Errorf("CHANGELOG.md = %q, want only the empty anchor %q", got, ChangelogAnchor) + } + gate := readFile(t, filepath.Join(dir, filepath.FromSlash(GateWorkflowPath))) + build := jobSection(t, gate, "build") + if !strings.Contains(build, "run: mkdir -p dist") || strings.Contains(build, "go build") { + t.Errorf("the gate's build job is not the named empty job:\n%s", build) + } + if strings.Contains(gate, "\n push:\n") { + t.Error("the gate workflow must not trigger on a tag push of its own") + } + auto := readFile(t, filepath.Join(dir, filepath.FromSlash(AutoReleaseYMLPath))) + if !strings.Contains(auto, "uses: ./.github/workflows/"+GateWorkflowName) { + t.Error("auto-release.yml does not call the gate workflow") + } + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(ReleaseYMLPath))); !os.IsNotExist(err) { + t.Error("a non-plugin kind must not receive release.yml") + } + written := map[string]bool{} + for _, f := range rep.Files { + if f.Status == StatusWritten { + written[f.Path] = true + } + } + for _, p := range []string{ChangelogPath, GateWorkflowPath, AutoReleaseYMLPath, RunbookPath, CheckReviewsPath} { + if !written[p] { + t.Errorf("the report does not name %s as written: %+v", p, rep.Files) + } + } + if rep.Wrote != len(written) { + t.Errorf("wrote=%d but %d files are reported written", rep.Wrote, len(written)) + } +} + +// An existing CHANGELOG.md is the release record: it is left alone and the +// report says so. +func TestScaffoldLeavesAnExistingChangelogAlone(t *testing.T) { + dir := kindRepo(t, "application") + existing := "# Changelog\n\n## [Unreleased]\n\n## [0.3.0] - 2026-01-01\n\n- history.\n" + mustWrite(t, filepath.Join(dir, ChangelogPath), existing) + rep, err := Scaffold(Request{RepoRoot: dir}) + if err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(dir, ChangelogPath)); got != existing { + t.Errorf("the existing changelog was rewritten:\n%s", got) + } + if f, ok := statusOf(rep, ChangelogPath); !ok || f.Status != StatusKept { + t.Errorf("CHANGELOG.md outcome %+v, want %q", f, StatusKept) + } +} + +// AC4: a repository with its own release workflow keeps it byte-for-byte; the +// gate is written beside it, and the report names the file as left alone and +// states what to add to it to call the gate. +func TestScaffoldLeavesAnExistingReleaseWorkflowByteForByte(t *testing.T) { + dir := kindRepo(t, "application") + own := "name: release\non:\n push:\n tags: ['v*']\njobs:\n build:\n runs-on: macos-latest\n steps:\n - run: make dmg\n" + ownPath := filepath.Join(dir, filepath.FromSlash(ReleaseYMLPath)) + mustWrite(t, ownPath, own) + + rep, err := Scaffold(Request{RepoRoot: dir}) + if err != nil { + t.Fatal(err) + } + if got := readFile(t, ownPath); got != own { + t.Fatalf("the repository's own release workflow changed:\n%s", got) + } + f, ok := statusOf(rep, ReleaseYMLPath) + if !ok || f.Status != StatusKept || !strings.Contains(f.Detail, "left alone") { + t.Errorf("release.yml outcome %+v, want it named as left alone", f) + } + if g, ok := statusOf(rep, GateWorkflowPath); !ok || g.Status != StatusWritten { + t.Errorf("gate outcome %+v, want written", g) + } + for _, want := range []string{"uses: ./.github/workflows/" + GateWorkflowName, "publish: false", "needs: abcd-release-gate"} { + if !strings.Contains(rep.CallStanza, want) { + t.Errorf("the stanza to add does not carry %q:\n%s", want, rep.CallStanza) + } + } + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(AutoReleaseYMLPath))); !os.IsNotExist(err) { + t.Error("a repository whose own workflow releases must not receive a second release chain") + } + runbook := readFile(t, filepath.Join(dir, filepath.FromSlash(RunbookPath))) + if !strings.Contains(runbook, ReleaseYMLPath) || !strings.Contains(runbook, GateWorkflowName) { + t.Errorf("the runbook does not describe the gate called from the repository's own workflow:\n%s", runbook) + } +} + +// AC5: a scaffolded gate workflow later edited by hand refuses the next run, +// naming the file, until --confirm. +func TestScaffoldRefusesAHandEditedGateWorkflowUntilConfirm(t *testing.T) { + dir := kindRepo(t, "binary") + if _, err := Scaffold(Request{RepoRoot: dir}); err != nil { + t.Fatal(err) + } + gatePath := filepath.Join(dir, filepath.FromSlash(GateWorkflowPath)) + mustWrite(t, gatePath, readFile(t, gatePath)+"# a hand edit\n") + + rep, err := Scaffold(Request{RepoRoot: dir}) + if !errors.Is(err, ErrScaffoldBlocked) { + t.Fatalf("err = %v, want ErrScaffoldBlocked", err) + } + if f, ok := statusOf(rep, GateWorkflowPath); !ok || f.Status != StatusRefused { + t.Errorf("gate outcome %+v, want refused by name", f) + } + if !strings.Contains(readFile(t, gatePath), "# a hand edit") { + t.Error("the refused hand edit was overwritten") + } + if _, err := Scaffold(Request{RepoRoot: dir, Confirm: true}); err != nil { + t.Fatalf("--confirm: %v", err) + } + if strings.Contains(readFile(t, gatePath), "# a hand edit") { + t.Error("--confirm did not restore the machinery") + } +} + +// iss-2608270559310755: the bare release workflow is gate plumbing plus a named +// empty build job. It builds nothing abcd guessed — no `make build`, no abcd-* +// assets — and publishes whatever the build job leaves in dist/. +func TestBareReleaseIsGatePlumbingPlusANamedEmptyBuildJob(t *testing.T) { + for _, gate := range []bool{false, true} { + subs := BareSubstitutions("main") + if gate { + subs = GateSubstitutions("main", "") + } + rendered, err := Render(subs) + if err != nil { + t.Fatal(err) + } + wf := string(rendered.ReleaseYML) + for _, banned := range []string{"make build", "abcd-*", "bin/abcd", "go build ./...\n\n - name: Create the GitHub Release"} { + if strings.Contains(wf, banned) { + t.Errorf("gate=%v: the bare release workflow still carries %q", gate, banned) + } + } + build := jobSection(t, wf, "build") + for _, want := range []string{"needs: [verify, tag]", "run: mkdir -p dist", "name: release-assets"} { + if !strings.Contains(build, want) { + t.Errorf("gate=%v: the build job does not carry %q:\n%s", gate, want, build) + } + } + release := jobSection(t, wf, "release") + for _, want := range []string{"needs: [verify, tag, build]", "needs.build.result == 'success'", `gh release create "${TAG}"`} { + if !strings.Contains(release, want) { + t.Errorf("gate=%v: the release job does not carry %q:\n%s", gate, want, release) + } + } + if strings.Contains(release, "go build") { + t.Errorf("gate=%v: the release job still builds with a guessed command", gate) + } + } +} diff --git a/internal/core/launch/scaffold/mergepath_release_test.go b/internal/core/launch/scaffold/mergepath_release_test.go index c6fd2ed95..30d1b4a73 100644 --- a/internal/core/launch/scaffold/mergepath_release_test.go +++ b/internal/core/launch/scaffold/mergepath_release_test.go @@ -45,16 +45,21 @@ func TestScaffoldedGateCutsAFirstReleaseThatPublishes(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash is required to run the workflow scripts") } - for _, semantic := range []bool{false, true} { - name := "bare" - if semantic { - name = "semantic-gate" - } - t.Run(name, func(t *testing.T) { cutFirstRelease(t, semantic) }) + for _, profile := range []string{"bare", "semantic-gate", "binary-gate"} { + t.Run(profile, func(t *testing.T) { cutFirstRelease(t, profile) }) } } -func cutFirstRelease(t *testing.T, semantic bool) { +// cutFirstRelease drives one profile: "bare" is a managed plugin repository's +// scaffold, "semantic-gate" the same with a receipt gate configured, and +// "binary-gate" a declared binary's scaffold, whose gate workflow auto-release +// calls in place of release.yml (itd-2609150819432059). +func cutFirstRelease(t *testing.T, profile string) { + semantic := profile == "semantic-gate" + workflow := ReleaseYMLPath + if profile == "binary-gate" { + workflow = GateWorkflowPath + } goEnv := hostGoEnv(t) // before gittest pins HOME, so the build cache stays warm f := newFakeForge(t, goEnv) r := f.work @@ -69,6 +74,11 @@ func cutFirstRelease(t *testing.T, semantic bool) { if semantic { adoptSemanticProfile(t, f, goEnv) } else { + kind := "plugin" + if profile == "binary-gate" { + kind = "binary" + } + r.Write(".abcd/config/artefact.json", `{"kind": "`+kind+`"}`+"\n") rep, err := Scaffold(Request{RepoRoot: r.Root()}) if err != nil { t.Fatalf("scaffold: %v", err) @@ -82,8 +92,8 @@ func cutFirstRelease(t *testing.T, semantic bool) { scaffolded := r.Git("rev-parse", "HEAD") // 1. The rehearsal, on record and green: a workflow_dispatch of release.yml. - rehearsal := f.run(ReleaseYMLPath, event{name: "workflow_dispatch", sha: scaffolded, refName: "main"}) - for job, want := range map[string]string{"verify": "success", "rehearsal": "success", "tag": "skipped", "release": "skipped"} { + rehearsal := f.run(workflow, event{name: "workflow_dispatch", sha: scaffolded, refName: "main"}) + for job, want := range map[string]string{"verify": "success", "rehearsal": "success", "tag": "skipped", "build": "skipped", "release": "skipped"} { if got := rehearsal[job].result; got != want { t.Fatalf("rehearsal: job %s = %s, want %s\n%s", job, got, want, f.log.String()) } diff --git a/internal/core/launch/scaffold/render.go b/internal/core/launch/scaffold/render.go index 75ac6c6ae..55141af77 100644 --- a/internal/core/launch/scaffold/render.go +++ b/internal/core/launch/scaffold/render.go @@ -71,6 +71,28 @@ type Substitutions struct { // (`--require-gate <name>`). Empty means no semantic detector is configured and // the deterministic gates alone admit the release (spc-14 clean degradation). SemanticGates []string + // ReleaseWorkflow is the file name of the workflow that verifies, tags and + // publishes — release.yml for a plugin, abcd-release-gate.yml for any other + // artefact kind — so auto-release calls it, the runbook names it and a + // receipt attestation names it as its signer by the name it has. + ReleaseWorkflow string + // Gate renders the release template as the gate workflow a non-plugin kind + // receives (itd-2609150819432059, decisions 5 and 6): no tag-push trigger of + // its own, so it never races a repository's own tag-driven workflow, and a + // `publish` input its caller turns off when it builds and publishes itself. + Gate bool + // OwnReleaseWorkflow is the repository's own release workflow, repo-relative, + // when it has one; the runbook then describes the gate as called from it. + OwnReleaseWorkflow string + // GoModule reports that the repository is a Go module (it carries a go.mod). + // The verify job's Go leg — setup-go, gofmt, build, vet, test and the race + // leg — and the rehearsal's build render only for one: a repository that is + // not a Go module would fail setup-go on the go.mod it does not have. + GoModule bool + // CallStanza is what a repository whose own release workflow stays in + // charge adds to it to call the gate; the runbook carries it, and the + // scaffold report prints it. + CallStanza string // CIChecks are the check names the managed repo's own pull-request CI // reports (DeriveCIChecks): the merge gate the release roll passes through. // The bare rendering names them in release.yml's verify header and lists them @@ -80,6 +102,15 @@ type Substitutions struct { CIChecks []string } +// ExtraBase is the number the runbook gives the first extra gate: it follows +// the five Go gates when the repository is a Go module, and leads otherwise. +func (s Substitutions) ExtraBase() int { + if s.GoModule { + return 6 + } + return 1 +} + // Rendered is the file set a scaffold run produces, keyed by repo-relative path. type Rendered struct { ReleaseYML []byte diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index efd8a6286..18b33cc21 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -9,6 +9,7 @@ import ( "strings" "syscall" + "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -34,6 +35,10 @@ const ( // StatusRefused — the file exists and differs, and --confirm was not given, so // it was left untouched. StatusRefused FileStatus = "refused" + // StatusKept — a file that belongs to the repository, not to abcd, and that + // the run never opened: an existing CHANGELOG.md, or the repository's own + // release workflow beside the gate. Detail says why it was left alone. + StatusKept FileStatus = "kept" // StatusSkipped — the file WOULD have been written, but the run refused (a // sibling was hand-edited) or a write faulted first, so it was NOT written. The // scaffold is all-or-nothing, so this reports honestly that nothing landed. @@ -61,7 +66,12 @@ type FileOutcome struct { // Report is the outcome of a scaffold run. type Report struct { Substitutions Substitutions `json:"-"` - DefaultBranch string `json:"default_branch"` + // Kind is the declared artefact kind the file set was chosen by. + Kind launch.ArtefactKind `json:"kind"` + // CallStanza is what to add to the repository's own release workflow to + // call the gate; present only when such a workflow was left alone. + CallStanza string `json:"call_stanza,omitempty"` + DefaultBranch string `json:"default_branch"` // GoVersion is what the scaffolded workflows will RESOLVE, not a value written // into them: they point setup-go at go.mod, so this reports the go directive // the run read. It is reported because an adopter should see which toolchain @@ -101,8 +111,21 @@ type Request struct { // A run that refuses any file returns ErrScaffoldBlocked with the report, so the // caller can render exactly what was and was not touched — no partial half-write. func Scaffold(req Request) (Report, error) { + // The file set is chosen by the declared artefact kind, so a repository + // that has not declared one — or declares one abcd does not know — is + // refused before anything is rendered or written (itd-2609150819432059). + art, err := launch.LoadArtefact(req.RepoRoot) + if err != nil { + return Report{}, err + } branch, goVersion := DeriveRepoFacts(req.RepoRoot) + own := "" subs := BareSubstitutions(branch) + if !art.IsPlugin() { + own = findOwnReleaseWorkflow(req.RepoRoot) + subs = GateSubstitutions(branch, own) + } + subs.GoModule = isGoModule(req.RepoRoot) subs.CIChecks = DeriveCIChecks(req.RepoRoot) if subs.CIChecks == nil { subs.CIChecks = []string{} // --json reports an empty list, never null @@ -112,15 +135,37 @@ func Scaffold(req Request) (Report, error) { return Report{}, err } - report := Report{Substitutions: subs, DefaultBranch: branch, GoVersion: goVersion, CIChecks: subs.CIChecks} - planned := []struct { - rel string - data []byte - }{ - {ReleaseYMLPath, rendered.ReleaseYML}, - {AutoReleaseYMLPath, rendered.AutoReleaseYML}, - {RunbookPath, rendered.Runbook}, - {CheckReviewsPath, rendered.CheckReviews}, + report := Report{Substitutions: subs, Kind: art.Kind, CallStanza: subs.CallStanza, + DefaultBranch: branch, GoVersion: goVersion, CIChecks: subs.CIChecks} + var planned []plannedFile + var kept []FileOutcome + if art.IsPlugin() { + planned = []plannedFile{ + {ReleaseYMLPath, rendered.ReleaseYML}, + {AutoReleaseYMLPath, rendered.AutoReleaseYML}, + {RunbookPath, rendered.Runbook}, + {CheckReviewsPath, rendered.CheckReviews}, + } + } else { + // The changelog is the release record once it exists, so it is laid + // only when absent and never drift-checked afterwards. + if _, err := os.Lstat(filepath.Join(req.RepoRoot, ChangelogPath)); err == nil { + kept = append(kept, FileOutcome{Path: ChangelogPath, Status: StatusKept, + Detail: "left alone: the changelog is this repository's release record"}) + } else { + planned = append(planned, plannedFile{ChangelogPath, []byte(ChangelogAnchor)}) + } + planned = append(planned, plannedFile{GateWorkflowPath, rendered.ReleaseYML}) + if own == "" { + planned = append(planned, plannedFile{AutoReleaseYMLPath, rendered.AutoReleaseYML}) + } else { + kept = append(kept, FileOutcome{Path: own, Status: StatusKept, + Detail: "left alone: this repository's own release workflow; add the job below to it so it calls " + + GateWorkflowName + " before its build step"}) + } + planned = append(planned, + plannedFile{RunbookPath, rendered.Runbook}, + plannedFile{CheckReviewsPath, rendered.CheckReviews}) } // First pass: classify every file WITHOUT writing. A refusal on any file with @@ -162,7 +207,7 @@ func Scaffold(req Request) (Report, error) { outcomes[i].Detail = "not written: the run refused because another file was hand-edited (all-or-nothing)" } } - report.Files = outcomes + report.Files = append(outcomes, kept...) report.Refused = refused return report, ErrScaffoldBlocked } @@ -181,7 +226,7 @@ func Scaffold(req Request) (Report, error) { // before this fault are marked written, this one and any later planned // file stay StatusSkipped (not written), so the report matches disk. outcomes[i].Detail = "not written: a write faulted on this file" - report.Files = outcomes + report.Files = append(outcomes, kept...) report.Wrote = wrote return report, fmt.Errorf("scaffold: write %s: %w", p.rel, err) } @@ -192,7 +237,7 @@ func Scaffold(req Request) (Report, error) { wrote++ } - report.Files = outcomes + report.Files = append(outcomes, kept...) report.Wrote = wrote report.NoOp = wrote == 0 return report, nil diff --git a/internal/core/launch/scaffold/scaffold_test.go b/internal/core/launch/scaffold/scaffold_test.go index 454d298d6..7ea7307f4 100644 --- a/internal/core/launch/scaffold/scaffold_test.go +++ b/internal/core/launch/scaffold/scaffold_test.go @@ -297,6 +297,7 @@ func TestReleaseJobGatedOffRehearsal(t *testing.T) { func TestScaffoldIdempotentAndRefusesHandEdit(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n") + declarePlugin(t, dir) gitInit(t, dir, "release-line") // First run: four files written — the two workflows, the runbook and the @@ -365,6 +366,7 @@ func TestScaffoldIdempotentAndRefusesHandEdit(t *testing.T) { func TestRefusalAbortReportMatchesDisk(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n") + declarePlugin(t, dir) gitInit(t, dir, "main") // auto-release.yml exists and differs (a hand-edit); release.yml is absent. @@ -431,6 +433,13 @@ func TestDeriveRepoFactsRejectsHostileInputs(t *testing.T) { // --- helpers --------------------------------------------------------------- +// declarePlugin declares the repository a plugin, the kind whose scaffold is +// release.yml, auto-release.yml, the runbook and the charter check. +func declarePlugin(t *testing.T, dir string) { + t.Helper() + mustWrite(t, filepath.Join(dir, ".abcd", "config", "artefact.json"), `{"kind": "plugin"}`+"\n") +} + func mustWrite(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -548,8 +557,11 @@ func TestRunbookGateListMatchesVerifySteps(t *testing.T) { "Semantic-gate receipts (fail-closed, before tag)": true, } itemRe := regexp.MustCompile(`^(\d+)\. (.+)$`) + notGo := GateSubstitutions("main", "") + notGo.GoModule = false for name, subs := range map[string]Substitutions{ "abcd": AbcdSubstitutions(), "bare": BareSubstitutions("main"), "bare+semantic": semantic, + "gate": GateSubstitutions("main", ""), "gate, not a Go module": notGo, } { rendered, err := Render(subs) if err != nil { diff --git a/internal/core/launch/scaffold/substitutions.go b/internal/core/launch/scaffold/substitutions.go index ff78cadc5..3ee9cca4e 100644 --- a/internal/core/launch/scaffold/substitutions.go +++ b/internal/core/launch/scaffold/substitutions.go @@ -27,10 +27,12 @@ var abcdExtraGates = []Gate{ // track the derivation rather than the proven workflow. func AbcdSubstitutions() Substitutions { return Substitutions{ - DefaultBranch: "main", - Abcd: true, - ExtraGates: abcdExtraGates, - SemanticGates: abcdSemanticGates, + DefaultBranch: "main", + Abcd: true, + ReleaseWorkflow: "release.yml", + GoModule: true, + ExtraGates: abcdExtraGates, + SemanticGates: abcdSemanticGates, } } @@ -50,9 +52,11 @@ var bareExtraGates = []Gate{ // because the rendered workflows read it out of the adopter's go.mod. func BareSubstitutions(defaultBranch string) Substitutions { return Substitutions{ - DefaultBranch: defaultBranch, - Abcd: false, - ExtraGates: bareExtraGates, - SemanticGates: nil, + DefaultBranch: defaultBranch, + Abcd: false, + ReleaseWorkflow: "release.yml", + GoModule: true, + ExtraGates: bareExtraGates, + SemanticGates: nil, } } diff --git a/internal/core/launch/scaffold/tagorder_workflow_test.go b/internal/core/launch/scaffold/tagorder_workflow_test.go index a93e533ea..e6d5a87a1 100644 --- a/internal/core/launch/scaffold/tagorder_workflow_test.go +++ b/internal/core/launch/scaffold/tagorder_workflow_test.go @@ -28,7 +28,7 @@ import ( // chain, and TestSelfScaffoldParity binds the Abcd rendering to the committed // workflows. func TestTheTagWaitsOnTheVerifyGate(t *testing.T) { - for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main")} { + for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main"), GateSubstitutions("main", "")} { rendered, err := Render(subs) if err != nil { t.Fatal(err) @@ -73,8 +73,14 @@ func TestTheTagWaitsOnTheVerifyGate(t *testing.T) { !strings.Contains(tag, `git tag -a "$TAG"`) || !strings.Contains(tag, `git push origin "refs/tags/$TAG"`) { t.Errorf("the tag job must tag the exact commit verify checked out and push only the tag ref (Abcd=%v)", subs.Abcd) } - if !strings.Contains(publish, "needs: [verify, tag]\n") { - t.Errorf("the publish job must need both verify and tag (Abcd=%v)", subs.Abcd) + // A managed profile publishes what its named build job built, so its + // publish job needs that job too (iss-2608270559310755). + needs := "needs: [verify, tag]\n" + if !subs.Abcd { + needs = "needs: [verify, tag, build]\n" + } + if !strings.Contains(publish, needs) { + t.Errorf("the publish job must need both verify and tag (Abcd=%v, Gate=%v): want %q", subs.Abcd, subs.Gate, needs) } // The publish job's condition is evaluated, not pattern-matched, by // TestThePublishConditionNeedsAGreenVerify below. @@ -106,40 +112,52 @@ func TestThePublishConditionNeedsAGreenVerify(t *testing.T) { t.Fatalf("read committed %s: %v", ReleaseYMLPath, err) } sources := map[string]string{ReleaseYMLPath: string(committed)} - for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main")} { + for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main"), GateSubstitutions("main", "")} { rendered, err := Render(subs) if err != nil { t.Fatal(err) } - sources[fmt.Sprintf("release.yml.tmpl (Abcd=%v)", subs.Abcd)] = string(rendered.ReleaseYML) + sources[fmt.Sprintf("release.yml.tmpl (Abcd=%v, Gate=%v)", subs.Abcd, subs.Gate)] = string(rendered.ReleaseYML) } results := []string{"success", "failure", "cancelled", "skipped"} for where, wf := range sources { cond := jobIf(t, jobSection(t, wf, "release"), where) + // A managed profile publishes only what its named build job built + // (iss-2608270559310755), and the gate publishes only when its caller + // asks; abcd's own profile has neither, and its condition reads neither. + hasBuild := strings.Contains(wf, "\n build:\n") + gate := strings.Contains(cond, "inputs.publish") for _, event := range []string{"push", "workflow_dispatch"} { for _, verify := range results { for _, tag := range results { - for _, cancelled := range []bool{false, true} { - ctx := map[string]any{ - "github.event_name": event, - "needs.verify.result": verify, - "needs.tag.result": tag, - "cancelled()": cancelled, - "success()": !cancelled && verify == "success" && tag == "success", - "failure()": verify == "failure" || tag == "failure", - } - got, err := actionsexpr.EvalIf(cond, ctx) - if err != nil { - t.Fatalf("%s: cannot evaluate the publish job's if %q: %v", where, cond, err) - } - want := event != "workflow_dispatch" && !cancelled && verify == "success" && - (tag == "success" || tag == "skipped") - if got != want { - t.Errorf("%s: the publish job's if %q is %v for event=%s verify=%s tag=%s "+ - "cancelled=%v, want %v: a release publishes only after a green verify, "+ - "with its tag made or not asked for, on an uncancelled non-rehearsal run", - where, cond, got, event, verify, tag, cancelled, want) + for _, build := range results { + for _, publish := range []bool{true, false} { + for _, cancelled := range []bool{false, true} { + ctx := map[string]any{ + "github.event_name": event, + "needs.verify.result": verify, + "needs.tag.result": tag, + "needs.build.result": build, + "inputs.publish": publish, + "cancelled()": cancelled, + "success()": !cancelled && verify == "success" && tag == "success", + "failure()": verify == "failure" || tag == "failure", + } + got, err := actionsexpr.EvalIf(cond, ctx) + if err != nil { + t.Fatalf("%s: cannot evaluate the publish job's if %q: %v", where, cond, err) + } + want := event != "workflow_dispatch" && !cancelled && verify == "success" && + (tag == "success" || tag == "skipped") && + (!hasBuild || build == "success") && (!gate || publish) + if got != want { + t.Errorf("%s: the publish job's if %q is %v for event=%s verify=%s tag=%s build=%s "+ + "publish=%v cancelled=%v, want %v: a release publishes only after a green verify, "+ + "with its tag made or not asked for, on an uncancelled non-rehearsal run", + where, cond, got, event, verify, tag, build, publish, cancelled, want) + } + } } } } diff --git a/internal/core/launch/scaffold/templates/auto-release.yml.tmpl b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl index 833be6cdd..5d99bb4f0 100644 --- a/internal/core/launch/scaffold/templates/auto-release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl @@ -2,14 +2,14 @@ name: auto-release # On every push to the default branch, tag-and-release the NEWEST dated # CHANGELOG version when it has no git tag yet — WITHOUT a PAT. A tag pushed with -# the built-in GITHUB_TOKEN deliberately does NOT re-trigger release.yml's -# tag-push event, so this workflow invokes release.yml directly as a reusable +# the built-in GITHUB_TOKEN deliberately does NOT re-trigger <% .ReleaseWorkflow %>'s +# tag-push event, so this workflow invokes <% .ReleaseWorkflow %> directly as a reusable # workflow (the `release` job below) and asks it to create the tag. That keeps # the whole publish path inside GitHub's own token model — no personal access # token, no elevated secret. # # The order is detect -> verify -> tag -> build -> publish. The tag is made -# INSIDE release.yml, by a job that needs its verify gate, never here before the +# INSIDE <% .ReleaseWorkflow %>, by a job that needs its verify gate, never here before the # call: a tag is immutable, so a tag cut ahead of a gate that then refuses # consumes the version with no Release behind it, and the heal path below would # rebuild the same refused commit on every later push (iss-2608231226347380, @@ -25,7 +25,7 @@ name: auto-release # sets need_release=true and re-invokes `release` ALONE — built from the tagged # commit (release_ref), never the moved-on HEAD — so a flaky publish never # permanently wedges the version. A tag its own verify refused is the exception: -# a hand-pushed tag runs release.yml before verify, and when that run's verify +# a hand-pushed tag runs <% .ReleaseWorkflow %> before verify, and when that run's verify # failed, `detect` refuses loudly and names the re-cut (a new dated version) # rather than rebuilding the refused commit on every push. on: @@ -47,7 +47,7 @@ permissions: jobs: # Decide whether the newest dated CHANGELOG version still needs a tag. Pure # read: no writes, no pushes. contents: read for the checkout and the Release - # lookup; actions: read to list release.yml's runs for a tag with no Release. + # lookup; actions: read to list <% .ReleaseWorkflow %>'s runs for a tag with no Release. detect: runs-on: ubuntu-latest timeout-minutes: 5 @@ -97,11 +97,11 @@ jobs: # ONLY when its GitHub Release is missing: a transient publish failure # must not permanently wedge the version. Build the re-release FROM the # tagged commit, not the current (moved-on) main HEAD — resolve the tag - # to its immutable commit SHA and hand it to release.yml as `ref`. + # to its immutable commit SHA and hand it to <% .ReleaseWorkflow %> as `ref`. echo "need_tag=false" >> "$GITHUB_OUTPUT" # Deliberate fail-open: ANY non-zero from `gh release view` (a true 404 # or a transient rate-limit/auth blip) counts as "Release missing" and - # re-releases. Safe — `gh release create` (release.yml) has no --clobber, + # re-releases. Safe — `gh release create` (<% .ReleaseWorkflow %>) has no --clobber, # so a false positive just errors on the existing release and nothing is # overwritten: one red run, no data loss. Parsing the error to isolate a # real 404 was rejected — it would hinge on gh's wording and could @@ -111,28 +111,28 @@ jobs: echo "$tag is tagged and released; nothing to do." echo "need_release=false" >> "$GITHUB_OUTPUT" else - # A refused gate is not healed. A hand-pushed tag runs release.yml + # A refused gate is not healed. A hand-pushed tag runs <% .ReleaseWorkflow %> # on its own push, before verify; when the newest such run's verify # job failed, the tagged commit is the one the gate refused, and # rebuilding it fails the same way on every later push # (iss-2609251125599536). The version is consumed: refuse loudly - # and name the re-cut. The tag auto-release asks release.yml to + # and name the re-cut. The tag auto-release asks <% .ReleaseWorkflow %> to # make is made only after verify is green and fires no push run, # so a heal on that path finds none and proceeds. A listing that # errors refuses too: guessing past it is the loop this closes. - if ! run_id="$(gh run list --workflow release.yml --event push --branch "$tag" \ + if ! run_id="$(gh run list --workflow <% .ReleaseWorkflow %> --event push --branch "$tag" \ --limit 1 --json databaseId --jq '.[0].databaseId // ""')"; then - echo "::error::cannot list release.yml's push runs for $tag, so cannot tell a refused verify from a failed publish; not rebuilding it." + echo "::error::cannot list <% .ReleaseWorkflow %>'s push runs for $tag, so cannot tell a refused verify from a failed publish; not rebuilding it." exit 1 fi if [ -n "$run_id" ]; then if ! verify="$(gh run view "$run_id" --json jobs \ --jq '[.jobs[] | select(.name == "verify") | .conclusion] | first // ""')"; then - echo "::error::cannot read the jobs of release.yml run $run_id for $tag; not rebuilding it." + echo "::error::cannot read the jobs of <% .ReleaseWorkflow %> run $run_id for $tag; not rebuilding it." exit 1 fi if [ "$verify" = failure ]; then - echo "::error::$tag was refused by its own verify gate (release.yml run $run_id), so rebuilding the tagged commit would fail the same way. The version is consumed: roll a new dated CHANGELOG version on a commit that carries the fix, and its push cuts that release." + echo "::error::$tag was refused by its own verify gate (<% .ReleaseWorkflow %> run $run_id), so rebuilding the tagged commit would fail the same way. The version is consumed: roll a new dated CHANGELOG version on a commit that carries the fix, and its push cuts that release." exit 1 fi fi @@ -142,49 +142,49 @@ jobs: echo "release_ref=$commit" >> "$GITHUB_OUTPUT" fi else - echo "$tag has no git tag; release.yml will verify, then tag and release, github.sha." + echo "$tag has no git tag; <% .ReleaseWorkflow %> will verify, then tag and release, github.sha." echo "need_tag=true" >> "$GITHUB_OUTPUT" echo "need_release=true" >> "$GITHUB_OUTPUT" fi - # Cut the release by calling release.yml as a reusable workflow. Because it is - # CALLED (not triggered by a tag push), github.sha inside release.yml is this + # Cut the release by calling <% .ReleaseWorkflow %> as a reusable workflow. Because it is + # CALLED (not triggered by a tag push), github.sha inside <% .ReleaseWorkflow %> is this # run's HEAD, so its verify gate, its tag job and its publish job all check out, # tag and ship exactly that commit — the same anti-tag-move property the - # tag-push entry point has. release.yml's tag job pushes the only ref this + # tag-push entry point has. <% .ReleaseWorkflow %>'s tag job pushes the only ref this # automation writes, a NEW tag, and only once verify is green. <%- if .Abcd %> # A called workflow is capped by the caller's grants, and that cap is - # transitive: release.yml in turn calls site.yml (adr-48), so this job hands + # transitive: <% .ReleaseWorkflow %> in turn calls site.yml (adr-48), so this job hands # down every scope BOTH workflows' jobs need (contents/id-token/attestations: # write). The three happen to be the same set, so the site deploy widens # nothing here. <%- else %> # A called workflow is capped by the caller's grants, so this job hands down - # every scope release.yml's jobs need (contents: write). + # every scope <% .ReleaseWorkflow %>'s jobs need (contents: write). <%- end %> release: needs: detect - # Run when a release is needed: a fresh version (need_tag, so release.yml + # Run when a release is needed: a fresh version (need_tag, so <% .ReleaseWorkflow %> # tags it after verify) or a tagged version whose Release is missing (the # heal path, built from the tagged commit). if: needs.detect.outputs.need_release == 'true' permissions: <%- if .Abcd %> - contents: write # release.yml: push the new tag after verify, create the Release; site.yml: attach site.tar.gz - id-token: write # release.yml + site.yml: OIDC for build-provenance attestation - attestations: write # release.yml: publish + read back the attestations; site.yml: attest site.tar.gz + contents: write # <% .ReleaseWorkflow %>: push the new tag after verify, create the Release; site.yml: attach site.tar.gz + id-token: write # <% .ReleaseWorkflow %> + site.yml: OIDC for build-provenance attestation + attestations: write # <% .ReleaseWorkflow %>: publish + read back the attestations; site.yml: attest site.tar.gz <%- else %> - contents: write # release.yml: push the new tag after verify, create the Release + contents: write # <% .ReleaseWorkflow %>: push the new tag after verify, create the Release <%- end %> - uses: ./.github/workflows/release.yml + uses: ./.github/workflows/<%.ReleaseWorkflow%> with: tag: v${{ needs.detect.outputs.version }} - # True only on the fresh-tag path: release.yml's tag job, which needs its + # True only on the fresh-tag path: <% .ReleaseWorkflow %>'s tag job, which needs its # verify gate, creates the tag at the commit verify passed. False on the # heal path, where the tag already exists and is never moved. create_tag: ${{ needs.detect.outputs.need_tag == 'true' }} - # Empty on the fresh-tag path → release.yml falls back to github.sha (the + # Empty on the fresh-tag path → <% .ReleaseWorkflow %> falls back to github.sha (the # commit it verifies and then tags). The resolved tagged-commit SHA on the re-release # path, so the Release is built from the tag, not a newer main HEAD. ref: ${{ needs.detect.outputs.release_ref }} @@ -194,7 +194,7 @@ jobs: # its caller passes: a job inside it that declares `environment:` gets the # environment applied — a deployment record is even created — but resolves # none of that environment's secrets unless `inherit` unlocked the context - # first. Necessary at EVERY level, so this line and release.yml's call to + # first. Necessary at EVERY level, so this line and <% .ReleaseWorkflow %>'s call to # site.yml are one mechanism, not two. # # Measured rather than reasoned, on a canary environment secret through this diff --git a/internal/core/launch/scaffold/templates/release.yml.tmpl b/internal/core/launch/scaffold/templates/release.yml.tmpl index da93b38b7..4ea38b0cd 100644 --- a/internal/core/launch/scaffold/templates/release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/release.yml.tmpl @@ -1,4 +1,4 @@ -name: release +name: <% if .Gate %>abcd-release-gate<% else %>release<% end %> <% if .Abcd %> # Pushing a version tag (vX.Y.Z) first re-runs the deterministic, Linux-only # verify gate (gofmt, build, vet, the race leg, and the record/docs/reviews @@ -17,6 +17,19 @@ name: release # checksums, and reported version all come from the pushed commit. Called by # auto-release with create_tag, the same run makes the tag itself, and only once # verify is green: detect -> verify -> tag -> build -> publish. +<%- else if .Gate %> +# abcd's release gate for this repository. It is CALLED — by auto-release.yml, or +# by this repository's own release workflow before its build step — and never +# triggered by a tag push of its own, so it cannot race a tag-driven workflow the +# repository already runs. It re-runs the verification gate against the commit +# being released; only if that gate is green does it make the tag (when asked), +# run the build job below and publish a GitHub Release from that same commit. A +# caller that builds and publishes itself passes `publish: false`, and the gate +# then verifies and tags only. Nothing is pushed to any branch, and the whole +# path runs on the built-in GITHUB_TOKEN. Every job checks out a resolved commit +# SHA, never a re-resolvable tag NAME. A manual workflow_dispatch runs the +# rehearsal (below), which arms the gate against a simulated release and +# publishes nothing. <%- else %> # Pushing a version tag (vX.Y.Z) re-runs the full verification gate against the # PUSHED COMMIT; only if that gate is green does it build and publish a GitHub @@ -29,9 +42,11 @@ name: release # publishes nothing. <%- end %> on: +<%- if not .Gate %> push: tags: - 'v*' +<%- end %> # Called by auto-release.yml to release the newest dated CHANGELOG version # (adr-37). `tag` and `ref` are an unenforced caller CONTRACT — --verify-tag # checks only that the tag exists, not that it points at `ref`; the sole @@ -54,6 +69,15 @@ on: required: false type: boolean default: false +<%- if .Gate %> + # False when the caller builds and publishes the release itself: the gate + # then verifies (and, with create_tag, tags) and its build and release + # jobs are skipped. + publish: + required: false + type: boolean + default: true +<%- end %> # Manual rehearsal. A workflow_dispatch runs the `rehearsal` job below alongside # `verify`, and nothing that publishes: the publish job (`release`) is gated off # this event, and the rehearsal arms the gate's resolution path against a @@ -74,7 +98,7 @@ env: # Never run two releases of the same tag concurrently, and never cancel a release # mid-flight — a half-published release is worse than a queued one. concurrency: - group: release-${{ inputs.tag || github.ref_name }} + group: <% if .Gate %>abcd-release-gate<% else %>release<% end %>-${{ inputs.tag || github.ref_name }} cancel-in-progress: false # Floor for the workflow: read-only. The tag job elevates itself to contents: @@ -95,8 +119,13 @@ jobs: # exercises exactly the commit whose binaries will ship. <%- else %> # Gate the release on the pushed commit: the deterministic verification gate +<%- if .GoModule %> # (gofmt, build, vet, test, the race leg). A red step here aborts before anything - # is built or published. Checked out at github.sha — the commit the pushed tag + # is built or published. +<%- else %> + # (the steps below; this repository is not a Go module, so it runs no Go leg). + # A red step here aborts before anything is built or published. +<%- end %> Checked out at github.sha — the commit the pushed tag # pointed at, never the default-branch tip or the re-resolvable tag name — so the # gate exercises exactly the commit that will ship. # @@ -142,6 +171,7 @@ jobs: <%- end %> fetch-depth: 0 persist-credentials: false +<%- if .GoModule %> - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -202,6 +232,7 @@ jobs: - name: Test (race) run: go test -race ./... <%- end %> +<%- end %> <%- range .ExtraGates %> - name: <% .Name %> @@ -332,12 +363,67 @@ jobs: # checks out and ships. git tag -a "$TAG" -m "abcd $TAG" "$COMMIT" git push origin "refs/tags/$TAG" +<% if not .Abcd %> + # The build, EMPTY BY DESIGN (iss-2608270559310755): abcd lays the gate + # plumbing and does not guess how this repository builds. Fill the step below, + # or replace this job's steps with a call to your own build, and leave every + # file the release should carry in dist/: the release job downloads dist/ and + # attaches each file in it to the GitHub Release. Left empty, the Release + # carries its generated notes alone. It runs from the commit verify gated, + # once the tag is in place, and never on the rehearsal. + build: + needs: [verify, tag] + if: github.event_name != 'workflow_dispatch' && !cancelled() && needs.verify.result == 'success' && (needs.tag.result == 'success' || needs.tag.result == 'skipped')<% if .Gate %> && inputs.publish<% end %> + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + assets: ${{ steps.assets.outputs.assets }} + steps: + - name: Check out the verified commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The commit verify gated, resolved exactly as verify resolved it. + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + + - name: Build the release assets into dist/ + # This repository's build goes here. TAG, the release tag, is in the + # environment; write every asset the release should carry into dist/. + run: mkdir -p dist + + - name: Count the release assets in dist/ + id: assets + run: | + set -euo pipefail + if [ -n "$(ls -A dist 2>/dev/null)" ]; then + echo "assets=true" >> "$GITHUB_OUTPUT" + else + echo "assets=false" >> "$GITHUB_OUTPUT" + fi + - name: Hand dist/ to the release job + if: steps.assets.outputs.assets == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-assets + path: dist/ + if-no-files-found: error + retention-days: 1 + + # Publish ONLY after verify is green and the build job succeeded, from the same + # commit verify gated, so the published assets match the code that passed — + # even if the tag were re-pointed since. + release: + needs: [verify, tag, build] +<%- else %> # Build and publish ONLY after verify is green. Binaries are cross-compiled from # the same pushed commit (github.sha) that verify just gated, so the shipped # artefacts match the code that passed — even if the tag were re-pointed since. release: needs: [verify, tag] +<%- end %> # The publish path never runs on a manual rehearsal (workflow_dispatch): that # event runs the `rehearsal` job, which publishes nothing. Otherwise it runs # once verify is green and the tag is in place — the tag job made it @@ -346,7 +432,11 @@ jobs: # need skips a job whose if uses no status function; it never fires on a # cancelled run, so a manual cancel cannot publish a half-finished state. A # FAILED tag job blocks the release, so a half-made tag never publishes. +<%- if .Abcd %> if: github.event_name != 'workflow_dispatch' && !cancelled() && needs.verify.result == 'success' && (needs.tag.result == 'success' || needs.tag.result == 'skipped') +<%- else %> + if: github.event_name != 'workflow_dispatch' && !cancelled() && needs.verify.result == 'success' && (needs.tag.result == 'success' || needs.tag.result == 'skipped') && needs.build.result == 'success'<% if .Gate %> && inputs.publish<% end %> +<%- end %> timeout-minutes: 20 runs-on: ubuntu-latest <%- if .Abcd %> @@ -415,6 +505,7 @@ jobs: env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +<%- if .GoModule %> - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -424,6 +515,7 @@ jobs: # leaves a job compiling on a toolchain the format gate would reject. go-version-file: go.mod cache: false +<%- end %> <%- if .SemanticGates %> # --- Semantic-gate provenance (iss-35; dormant until the public flip) ------ @@ -482,7 +574,7 @@ jobs: for r in .abcd/work/reviews/"$CONTENT_SHA"/*.json; do gh attestation verify "$r" \ --repo "${GITHUB_REPOSITORY}" \ - --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/<% .ReleaseWorkflow %>" \ --predicate-type https://abcd.dev/attestations/semantic-release-gate/v1 done <%- end %> @@ -581,7 +673,7 @@ jobs: --dir "$DL" gh attestation verify "$DL/abcd-linux-amd64" \ --repo "${GITHUB_REPOSITORY}" \ - --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/<% .ReleaseWorkflow %>" # The plugin archive too: fresh from the Release, attested by this # workflow, and byte-identical to the archive verified against the pin. gh release download "${TAG}" \ @@ -590,21 +682,33 @@ jobs: --dir "$DL" gh attestation verify "$DL/abcd-plugin-${TAG}.zip" \ --repo "${GITHUB_REPOSITORY}" \ - --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/<% .ReleaseWorkflow %>" cmp "$DL/abcd-plugin-${TAG}.zip" "bin/abcd-plugin-${TAG}.zip" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} <%- else %> - - name: Build - run: go build ./... + - name: Receive the build job's dist/ + if: needs.build.outputs.assets == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: dist/ - - name: Create the GitHub Release and generate notes + - name: Create the GitHub Release, attach dist/ and generate notes # --verify-tag: refuse to publish unless the tag already exists remotely — # existence only; it does not check what the tag points at (see the contract - # note at the top of this file). --generate-notes lists the merged PRs - # since the previous tag; CHANGELOG.md remains the durable, hand-curated - # record. GITHUB_TOKEN-only: no personal access token, no elevated secret. - run: gh release create "${TAG}" --verify-tag --title "${TAG}" --generate-notes + # note at the top of this file). Every file the build job left in dist/ is + # attached; with none, the Release carries its notes alone. --generate-notes + # lists the merged PRs since the previous tag; CHANGELOG.md remains the + # durable record. GITHUB_TOKEN-only: no personal access token, no elevated + # secret. + run: | + set -euo pipefail + assets=() + if [ -d dist ]; then + while IFS= read -r -d '' f; do assets+=("$f"); done < <(find dist -type f -print0 | sort -z) + fi + gh release create "${TAG}" ${assets[@]+"${assets[@]}"} --verify-tag --title "${TAG}" --generate-notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} <%- end %> @@ -731,9 +835,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history so the content-commit resolution below can walk parents, - # exactly as release.yml's semantic gate does. + # exactly as <% .ReleaseWorkflow %>'s semantic gate does. fetch-depth: 0 persist-credentials: false +<%- if .GoModule %> - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -743,6 +848,7 @@ jobs: # leaves a job compiling on a toolchain the format gate would reject. go-version-file: go.mod cache: false +<%- end %> <%- if .SemanticGates %> - name: Simulate a changelog roll and a BATCHED merge-queue push @@ -794,8 +900,8 @@ jobs: git merge -q --no-ff -m 'rehearsal: merge unrelated batch-mate' rehearsal-unrelated echo "REHEARSAL_CONTENT_SHA=$content" >> "$GITHUB_ENV" - - name: Resolve the reviewed content commit (release.yml's derivation) - # Mirrors release.yml's derivation exactly: the content commit is read from + - name: Resolve the reviewed content commit (<% .ReleaseWorkflow %>'s derivation) + # Mirrors <% .ReleaseWorkflow %>'s derivation exactly: the content commit is read from # the RECEIPTS DIRECTORY of the released tree, not HEAD^2^/HEAD^ ancestry. # Asserts it lands on the simulated content commit AND that the old HEAD^2^ # derivation would NOT — the iss-355 regression the batched shape above @@ -850,8 +956,8 @@ jobs: git merge -q --no-ff -m 'rehearsal: merge simulated release' rehearsal-sim echo "REHEARSAL_CONTENT_SHA=$content" >> "$GITHUB_ENV" - - name: Resolve the reviewed content commit (release.yml's derivation) - # This profile arms no receipt gate, so release.yml derives no content + - name: Resolve the reviewed content commit (<% .ReleaseWorkflow %>'s derivation) + # This profile arms no receipt gate, so <% .ReleaseWorkflow %> derives no content # commit. The step proves the two-commit release shape instead: HEAD^2^ # of the merge (HEAD^ on a direct tag) lands on the simulated content # commit, an ancestor of the released commit, which is the commit the @@ -881,7 +987,9 @@ jobs: - name: Prove the gate admits and nothing was published run: | set -euo pipefail +<%- if .GoModule %> # Build the simulated tree — the deterministic leg the gate requires. go build ./... +<%- end %> echo "rehearsal: gate arming resolved; the deterministic gates admit the simulated roll." echo "rehearsal: nothing published — no tag, Release, or attestation was created (contents: read)." diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl index 73d72aa4b..2baede145 100644 --- a/internal/core/launch/scaffold/templates/runbook.md.tmpl +++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl @@ -2,9 +2,24 @@ This repo cuts releases the changelog-driven way (adr-37): rolling the accumulated `## [Unreleased]` section into a dated `## [X.Y.Z] - <date>` heading, -in an ordinary reviewed PR, **is** the release decision. On merge to +in an ordinary reviewed PR, **is** the release decision. +<%- if .OwnReleaseWorkflow %> This repository's own +release workflow, `<% .OwnReleaseWorkflow %>`, stays in charge of building and +publishing, and `abcd launch scaffold` leaves it byte-for-byte as it is. It calls +abcd's gate, `<% .ReleaseWorkflow %>`, before its build step: the gate verifies +exactly the commit being released and refuses before anything is built, so a +refused gate publishes nothing. Add this job to `<% .OwnReleaseWorkflow %>` and +make the job that builds need it: + +```yaml +<% .CallStanza %>``` + +The gate runs on the built-in `GITHUB_TOKEN` — no personal access token, no +standing secret — and with `publish: false` it builds and publishes nothing +itself. +<%- else %> On merge to `<% .DefaultBranch %>`, `auto-release.yml` reads the newest dated heading and -calls `release.yml`, which verifies exactly that commit, tags it only once the +calls `<% .ReleaseWorkflow %>`, which verifies exactly that commit, tags it only once the gates pass, and publishes. A refused gate leaves no tag, so the version stays free. Nothing else declares a release; an ordinary push with no new dated heading is a no-op. @@ -15,10 +30,11 @@ no standing secret. Tags are immutable; a publish is idempotent and self-healing The one tag it does not heal is a hand-pushed tag its own `verify` refused: rebuilding that commit would fail the same way, so `auto-release.yml` refuses loudly and names the re-cut, a new dated version. +<%- end %> ## Before the first real release: run the rehearsal -`release.yml` carries a `workflow_dispatch` **rehearsal**. Trigger it once before +`<% .ReleaseWorkflow %>` carries a `workflow_dispatch` **rehearsal**. Trigger it once before the first public release. It arms the full gate against a simulated changelog roll and reviewed-content commit, asserts the gate admits it, and **publishes nothing** — no tag, no Release, no attestation. A green rehearsal is the @@ -48,7 +64,7 @@ rewrites them with the new list. triggered by `pull_request` or `merge_group`, so nothing but review gates the release roll's merge. Add a CI workflow for pull requests, require its checks on `<% .DefaultBranch %>`, and re-run `abcd launch scaffold --confirm` to record -them here and in `release.yml`. +them here and in `<% .ReleaseWorkflow %>`. <%- end %> ## Workflow audit — what abcd checked, and what it did not @@ -63,9 +79,9 @@ Run zizmor, or the workflow auditor this repository already uses, over <% end -%> ## Deterministic gates (CI-enforced) -The `release.yml` `verify` job runs these, in order, on the released commit. -`release.yml` is authoritative; this list is the human-readable mirror. - +The `<% .ReleaseWorkflow %>` `verify` job runs these, in order, on the released commit. +`<% .ReleaseWorkflow %>` is authoritative; this list is the human-readable mirror. +<% if .GoModule %> 1. Format (gofmt) 2. Build 3. Vet @@ -75,20 +91,21 @@ The `release.yml` `verify` job runs these, in order, on the released commit. <%- else %> 5. Test (race) <%- end %> +<%- end %> <%- range $i, $g := .ExtraGates %> -<% add $i 6 %>. <% $g.Name %> +<% add $i $.ExtraBase %>. <% $g.Name %> <%- end %> <%- if .Abcd %> <% add (len .ExtraGates) 6 %>. Plugin archive reproduces the committed pin (fail-closed) <%- end %> <%- if .SemanticGates %> -<% if .Abcd %><% add (len .ExtraGates) 7 %><% else %><% add (len .ExtraGates) 6 %><% end %>. The release tag names the released CHANGELOG version (fail-closed) +<% if .Abcd %><% add (len .ExtraGates) 7 %><% else %><% add (len .ExtraGates) .ExtraBase %><% end %>. The release tag names the released CHANGELOG version (fail-closed) <%- end %> <%- if .Abcd %> The plugin-archive gate also checks that the pinned address is this repository's own release. On the `auto-release.yml` path every gate here runs before the tag — -`release.yml`'s `tag` job needs `verify` — so a refusal tags nothing, and the +`<% .ReleaseWorkflow %>`'s `tag` job needs `verify` — so a refusal tags nothing, and the next push to `<% .DefaultBranch %>` retries. A hand-pushed tag exists before `verify` runs, so a refusal there consumes the version. <%- end %> @@ -105,7 +122,7 @@ A receipt names the commit its reviewer **read**, and lives in a **later** commit — it can never sit in the tree of the commit it names (adding it would change that commit's sha). So the release branch is two commits: the CHANGELOG roll (the reviewed content commit), then the receipts naming it. On merge, -`release.yml` arms the gate with the **content** commit, derived from the +`<% .ReleaseWorkflow %>` arms the gate with the **content** commit, derived from the receipts directory of the released tree — the nearest commit a `.abcd/work/reviews/<full-sha>/` entry names among those carrying this release's own CHANGELOG version — so the receipt-vs-tag self-reference never @@ -158,14 +175,21 @@ and a release's own receipts never fail the charter. 3. Run `abcd launch receipts`; it exits 0 only when the release job's receipt gate would admit the branch. 4. Open the pull request and merge it once the merge gate is green. -5. `auto-release.yml` has `release.yml` verify, tag `vX.Y.Z` on the merged +5. `auto-release.yml` has `<% .ReleaseWorkflow %>` verify, tag `vX.Y.Z` on the merged commit and publish. No tag is made unless every semantic receipt is present and PROMOTE. +<%- else if .OwnReleaseWorkflow -%> +1. Land all work; open the release the normal way (branch → PR → merge), rolling + `## [Unreleased]` into the dated heading. The merge gate above gates the + merge. +2. Tag the merged commit `vX.Y.Z` the way this repository does; `<% .OwnReleaseWorkflow %>` + calls `<% .ReleaseWorkflow %>`, whose deterministic `verify` job gates the build + and the publish that follow it. <%- else -%> 1. Land all work; open the release the normal way (branch → PR → merge), rolling `## [Unreleased]` into the dated heading. The merge gate above gates the merge. -2. `auto-release.yml` has `release.yml` verify, tag `vX.Y.Z` on the merged +2. `auto-release.yml` has `<% .ReleaseWorkflow %>` verify, tag `vX.Y.Z` on the merged commit and publish; the tag and the Release are gated on the deterministic `verify` job alone. <%- end %> diff --git a/internal/core/launch/scaffold/wiring_test.go b/internal/core/launch/scaffold/wiring_test.go index 37c6ab7df..0943ddb26 100644 --- a/internal/core/launch/scaffold/wiring_test.go +++ b/internal/core/launch/scaffold/wiring_test.go @@ -109,6 +109,7 @@ func TestScaffoldWiresTheRepositorysOwnCIChecks(t *testing.T) { mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n") mustWrite(t, filepath.Join(dir, ".github", "workflows", "ci.yml"), managedCI) gitInit(t, dir, "trunk") + declarePlugin(t, dir) rep, err := Scaffold(Request{RepoRoot: dir}) if err != nil { @@ -141,6 +142,7 @@ func TestScaffoldWiresTheRepositorysOwnCIChecks(t *testing.T) { bare := t.TempDir() mustWrite(t, filepath.Join(bare, "go.mod"), "module example.com/y\n\ngo 1.22\n") gitInit(t, bare, "main") + declarePlugin(t, bare) if rep, err = Scaffold(Request{RepoRoot: bare}); err != nil { t.Fatal(err) } @@ -234,6 +236,8 @@ func TestScaffoldedWorkflowsPassTheWorkflowAudit(t *testing.T) { "bare": BareSubstitutions("main"), "bare+ci-checks": withChecks, "bare+semantic": semantic, + "gate": GateSubstitutions("main", ""), + "gate+own": GateSubstitutions("main", ".github/workflows/release.yml"), } for name, subs := range profiles { rendered, err := Render(subs) @@ -265,13 +269,16 @@ func TestScaffoldedWorkflowsPassTheWorkflowAudit(t *testing.T) { // a branch name, a commit message) is in it, so an expression reading one // fails the strict evaluation. var trustedContext = map[string]any{ - "inputs.tag": "v1.2.3", "inputs.ref": "", "inputs.create_tag": true, + "inputs.tag": "v1.2.3", "inputs.ref": "", "inputs.create_tag": true, "inputs.publish": true, "github.sha": strings.Repeat("a", 40), "github.ref_name": "v1.2.3", "github.token": "t", "github.event_name": "push", "github.repository": "example/fixture", "github.event.repository.default_branch": "main", "github.event.repository.private": false, "secrets.GITHUB_TOKEN": "t", "needs.verify.result": "success", "needs.tag.result": "success", "needs.release.result": "success", "needs.verify.outputs.content_sha": strings.Repeat("b", 40), + "needs.build.result": "success", + "needs.build.outputs.assets": "false", + "steps.assets.outputs.assets": "false", "needs.detect.outputs.version": "1.2.3", "needs.detect.outputs.need_tag": "true", "needs.detect.outputs.need_release": "true", diff --git a/internal/core/site/releasechainsecrets_test.go b/internal/core/site/releasechainsecrets_test.go index 259c10c6c..4f4bac892 100644 --- a/internal/core/site/releasechainsecrets_test.go +++ b/internal/core/site/releasechainsecrets_test.go @@ -39,7 +39,10 @@ func TestReleaseChainPassesSecretsAtEveryLevel(t *testing.T) { }{ {".github/workflows/auto-release.yml", "./.github/workflows/release.yml"}, {".github/workflows/release.yml", "./.github/workflows/site.yml"}, - {"internal/core/launch/scaffold/templates/auto-release.yml.tmpl", "./.github/workflows/release.yml"}, + // The template names the workflow it calls by substitution: release.yml + // for a plugin, the gate workflow for any other kind (spelt without spaces + // so the call reads as one token). + {"internal/core/launch/scaffold/templates/auto-release.yml.tmpl", "./.github/workflows/<%.ReleaseWorkflow%>"}, {"internal/core/launch/scaffold/templates/release.yml.tmpl", "./.github/workflows/site.yml"}, } { t.Run(tc.file, func(t *testing.T) { diff --git a/internal/surface/cli/launch_kind_test.go b/internal/surface/cli/launch_kind_test.go new file mode 100644 index 000000000..6ba1247a3 --- /dev/null +++ b/internal/surface/cli/launch_kind_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// itd-2609150819432059: every launch verb runs against the declared artefact +// kind, and the release-cut gate reaches a kind that is not a plugin. + +// binaryShipFixture is shipFixture declared as a managed Go binary: no plugin +// manifest, no payload include config, released at v0.4.0. +func binaryShipFixture(t *testing.T) *gittest.Repo { + t.Helper() + r := shipFixture(t) + r.Remove(".claude-plugin/plugin.json") + r.Remove(".claude-plugin/marketplace.json") + r.Write(".abcd/config/artefact.json", `{"kind": "binary"}`+"\n") + r.Write("main.go", "package main\n\nfunc main() {}\n") + data, err := GenerateSurface(r.Root()) + if err != nil { + t.Fatalf("GenerateSurface: %v", err) + } + r.Write(SurfaceSnapshotPath, string(data)) + r.Commit("the released binary") + r.Git("tag", "-f", "v0.4.0") + return r +} + +// AC9: a kind the binary does not know refuses every launch verb, naming the +// kind and the accepted set, and writes nothing. +func TestEveryLaunchVerbRefusesAnUnknownKind(t *testing.T) { + verbs := map[string][]string{ + "dry-run": {"launch", "--dry-run"}, + "ship": {"launch", "ship"}, + "scaffold": {"launch", "scaffold"}, + "receipts": {"launch", "receipts"}, + "archive": {"launch", "archive", "--out", "OUT"}, + } + for name, args := range verbs { + t.Run(name, func(t *testing.T) { + r := binaryShipFixture(t) + r.Write(".abcd/config/artefact.json", `{"kind": "container-image"}`+"\n") + r.Commit("declare a kind abcd does not know") + out := t.TempDir() + for i, a := range args { + if a == "OUT" { + args[i] = out + } + } + stdout, err := shipIn(t, r, args...) + if err == nil { + t.Fatalf("%v accepted an unknown kind:\n%s", args, stdout) + } + for _, want := range []string{`"container-image"`, "plugin, binary, application"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%v: the refusal does not name %q: %v", args, want, err) + } + } + if status := r.Git("status", "--porcelain", "--ignored"); status != "" { + t.Errorf("%v wrote into the repository:\n%s", args, status) + } + if entries, _ := os.ReadDir(out); len(entries) != 0 { + t.Errorf("%v wrote into --out: %v", args, entries) + } + }) + } +} + +// AC6: a declared binary's cut carrying an open major captured since the +// anchor tag, with no deferral, refuses naming the record, as a plugin's does. +func TestLaunchShipForADeclaredBinaryRefusesAnOpenMajor(t *testing.T) { + r := binaryShipFixture(t) + r.Write(".abcd/development/intents/shipped/itd-73-x.md", "---\nid: itd-73\nimpact: additive\n---\n# x\n") + r.Write(".abcd/work/issues/open/iss-90-found-while-shipping.md", + "---\nid: \"iss-90\"\nseverity: \"major\"\n---\n\nfound while shipping.\n") + r.Commit("ship an intent and capture a major") + + out, err := shipIn(t, r, "launch", "ship") + if code := exitCodeOf(err); code != 1 { + t.Fatalf("exit = %d, want 1 (the cut refuses)\n%s", code, out) + } + if !strings.Contains(string(out), "iss-90") { + t.Errorf("the refusal does not name the record:\n%s", out) + } +} + +// AC7: the same cut with the major deferred out loud passes, and the report +// names the deferred record. +func TestLaunchShipForADeclaredBinaryPassesAndNamesADeferral(t *testing.T) { + r := binaryShipFixture(t) + r.Write(".abcd/development/intents/shipped/itd-73-x.md", "---\nid: itd-73\nimpact: additive\n---\n# x\n") + r.Write(".abcd/work/issues/open/iss-90-found-while-shipping.md", + "---\nid: \"iss-90\"\nseverity: \"major\"\ndeferred_after: \"v0.4.0\"\n"+ + "deferral_reason: \"the fix needs a schema migration\"\n---\n\nheld over.\n") + r.Commit("ship an intent and defer what shipping it turned up") + + out, err := shipIn(t, r, "launch", "ship") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("exit = %d, want 0\n%s", code, out) + } + if !strings.Contains(string(out), "deferred: iss-90") || !strings.Contains(string(out), "schema migration") { + t.Errorf("the report does not name the deferred record and its reason:\n%s", out) + } +} + +// A declared binary has no plugin payload, so --payload-dir is refused before +// anything is read or written. +func TestLaunchShipRefusesAPayloadDirForANonPluginKind(t *testing.T) { + r := binaryShipFixture(t) + dest := filepath.Join(t.TempDir(), "payload") + _, err := shipIn(t, r, "launch", "ship", "--changelog-json", "-", "--payload-dir", dest) + if code := exitCodeOf(err); code != 2 || !strings.Contains(err.Error(), "binary") { + t.Fatalf("exit %d (%v), want 2 naming the declared kind", code, err) + } + if _, statErr := os.Stat(dest); statErr == nil { + t.Error("the refused ship staged a payload directory") + } +} + +// AC4 at the front door: the repository's own release workflow is named as left +// alone, and the stanza to add to it is printed. +func TestLaunchScaffoldForABinaryWithItsOwnReleaseWorkflowPrintsTheStanza(t *testing.T) { + r := binaryShipFixture(t) + own := "name: release\non:\n push:\n tags: ['v*']\njobs:\n build:\n runs-on: macos-latest\n steps:\n - run: make dmg\n" + r.Write(".github/workflows/release.yml", own) + r.Commit("the repository's own release workflow") + + out, err := shipIn(t, r, "launch", "scaffold") + if err != nil { + t.Fatalf("scaffold: %v\n%s", err, out) + } + for _, want := range []string{"kind binary", "[kept] .github/workflows/release.yml", "[written] .github/workflows/abcd-release-gate.yml", + "uses: ./.github/workflows/abcd-release-gate.yml", "publish: false"} { + if !strings.Contains(string(out), want) { + t.Errorf("the scaffold report does not say %q:\n%s", want, out) + } + } + if got := readFileString(t, filepath.Join(r.Root(), ".github/workflows/release.yml")); got != own { + t.Errorf("the repository's own release workflow changed:\n%s", got) + } +} diff --git a/internal/surface/cli/scaffold.go b/internal/surface/cli/scaffold.go index ba82a04ca..f880eeef7 100644 --- a/internal/surface/cli/scaffold.go +++ b/internal/surface/cli/scaffold.go @@ -13,10 +13,16 @@ import ( ) // newLaunchScaffoldCommand builds `abcd launch scaffold` (itd-93, spc-14): it -// writes the changelog-driven release machinery — release.yml, auto-release.yml, -// the adr-37 runbook and the reviews-charter check — into a managed repo that -// lacks it, wired to the repo's own default branch and pull-request CI check -// names, GITHUB_TOKEN-only and injection-safe. +// writes the changelog-driven release machinery into a managed repo that lacks +// it, wired to the repo's own default branch and pull-request CI check names, +// GITHUB_TOKEN-only and injection-safe. The file set follows the artefact kind +// the repository declares (itd-2609150819432059): a plugin receives release.yml, +// auto-release.yml, the adr-37 runbook and the reviews-charter check; any other +// kind receives the gate workflow abcd-release-gate.yml with a named empty build +// job, the runbook, the charter check, the empty [Unreleased] anchor when it has +// no changelog, and auto-release.yml unless its own release workflow — left +// byte-for-byte — stays in charge. A repository that has declared no kind, or a +// kind abcd does not know, is refused before anything is written (exit 2). // // It is idempotent and fail-safe (AC4): a re-run on current machinery is a no-op, // and a hand-edited file is refused (exit 1) rather than clobbered unless @@ -30,7 +36,7 @@ func newLaunchScaffoldCommand(asJSON *bool) *cobra.Command { var confirm bool cmd := &cobra.Command{ Use: "scaffold [--confirm]", - Short: "Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook, reviews charter) into this repo", + Short: "Scaffold the changelog-driven release gate into this repo, shaped by its declared artefact kind", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { cwd, err := os.Getwd() @@ -80,8 +86,8 @@ func renderScaffold(w io.Writer, rep scaffold.Report, blocked bool) { case rep.NoOp: verdict = "no-op (already current)" } - fmt.Fprintf(w, "abcd launch scaffold — %s (branch %s, go %s)\n", - verdict, termsafe.Sanitize(rep.DefaultBranch), termsafe.Sanitize(rep.GoVersion)) + fmt.Fprintf(w, "abcd launch scaffold — %s (kind %s, branch %s, go %s)\n", + verdict, termsafe.Sanitize(string(rep.Kind)), termsafe.Sanitize(rep.DefaultBranch), termsafe.Sanitize(rep.GoVersion)) if len(rep.CIChecks) > 0 { fmt.Fprintf(w, " merge gate: %s (require these on %s)\n", termsafe.Sanitize(strings.Join(rep.CIChecks, ", ")), termsafe.Sanitize(rep.DefaultBranch)) @@ -97,6 +103,14 @@ func renderScaffold(w io.Writer, rep scaffold.Report, blocked bool) { fmt.Fprintln(w) } fmt.Fprintf(w, " %d written, %d refused\n", rep.Wrote, rep.Refused) + // The repository's own release workflow is left alone, so what to add to it + // is printed rather than written: the stanza is abcd's own text. + if rep.CallStanza != "" { + fmt.Fprintln(w, " to call the gate, add this to your own release workflow:") + for _, line := range strings.Split(strings.TrimRight(rep.CallStanza, "\n"), "\n") { + fmt.Fprintf(w, " %s\n", line) + } + } if blocked { fmt.Fprintln(w, " re-run with --confirm to overwrite the hand-edited file(s) with the machinery.") } From 5a72b2bdade91620f1f30c1c930bc105919daaa9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:51:27 +0100 Subject: [PATCH 052/107] feat(ahoy): an undeclared artefact kind is a gap install closes A managed repository with no .abcd/config/artefact.json raises a required, resolvable artefact.missing gap (itd-2609150819432059, decision 3). Once config changes are approved, install declares kind plugin without a question for a repository carrying .claude-plugin/plugin.json, and otherwise asks the kind, last of the install's questions. As the house-style question does, an unattended --yes run is not asked and an answer naming no kind (the `y` of `yes |`) is not refused: both declare application, the kind that assumes least about the build, with a note saying what was heard. The file is proved through the launch reader before it is written, under an os.Root. A declaration that is present and refused is a non-resolvable artefact.invalid diagnostic; install never overwrites it. ahoy functions touched: Detect (one appended gap source) and Install (one appended step, stepArtefact); everything else is in artefact.go. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 20 +- commands/ahoy.md | 17 ++ internal/core/ahoy/apply.go | 3 + internal/core/ahoy/artefact.go | 151 +++++++++++++ internal/core/ahoy/artefact_test.go | 208 ++++++++++++++++++ internal/core/ahoy/detect.go | 3 + 6 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 internal/core/ahoy/artefact.go create mode 100644 internal/core/ahoy/artefact_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index e02c8a80b..e499b9ed6 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -313,11 +313,29 @@ about, one question per category present, never one per item. | `category` | Examples | Apply behaviour | |---|---|---| | `safe-autocreate` | the repo skeleton, history-store directories, the name-guard artefacts | applied once the category is approved, no per-item prompt; create-if-absent, never overwriting | -| `config-change` | visibility, oracle adapter, the `PATH` entry, the git-identity pin | transparent confirm; skip-if-set with a "current value" notice | +| `config-change` | visibility, oracle adapter, the `PATH` entry, the git-identity pin, the artefact kind | transparent confirm; skip-if-set with a "current value" notice | | `plugin-owned` | the marker block (itd-3); hook-manifest verification | silent overwrite on marker drift; a non-resolvable diagnostic for a malformed or missing manifest | | `dependency` | the opt-in scanners | one category-level approval covering them; abcd never auto-executes a package manager, and the user runs the commands | | `user-state` | the registry entry, re-founding, stale or duplicate entries | guided; never auto-edit user-scope state, report extras read-only | +**The artefact kind is a gap until it is declared** (itd-2609150819432059). A +managed repository with no `.abcd/config/artefact.json` raises a required, +resolvable `artefact.missing` gap, because the launch verbs choose what to +preview, check and scaffold by the kind declared there and refuse to guess it. +The apply pass writes the file once config changes are approved: a repository +carrying `.claude-plugin/plugin.json` takes `kind: plugin` without a question, +so the shipped shape adopts silently; any other is asked its kind, last of all +the install's questions. An unanswered prompt takes `application`, the kind that +assumes least about the build. An unattended install is not asked, and an +answer naming none of `plugin`, `binary` and `application` is not refused: both +declare `application` with a note saying what was heard, as the house-style +question does, because withholding the declaration would leave every launch verb +refusing the repository. The file is validated by +the one reader the launch verbs share, before it is written and whenever it is +read, so a declaration that is present and refused raises a non-resolvable +`artefact.invalid` diagnostic instead: it is the user's file, and the install +never overwrites it. + **The questions come in a fixed order**, and the order is a contract rather than a presentation choice: answers are positional, so without it the Nth piped answer approves a different category on every run — a wrong answer that exits 0 diff --git a/commands/ahoy.md b/commands/ahoy.md index 42963c7dc..15f175498 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -150,6 +150,23 @@ configuration values and before the status-line offer, and is asked only when the config is being created: a repository that already has one keeps its own severity. +**The artefact kind.** A repository with no `.abcd/config/artefact.json` carries +an `artefact.missing` gap: the launch verbs choose what to preview, check and +scaffold by the kind declared there, and refuse to guess it. Once config changes +are approved, a repository carrying `.claude-plugin/plugin.json` is declared +`kind: plugin` without a question. Any other is asked +`artefact_kind (plugin/binary/application) [application]`: relay it and pass on +the user's answer, never answering for them. End of input or a bare Enter takes +`application` — gate plumbing with an empty build job, assuming nothing about the +build. `--yes` does not ask and declares `application`, and the result's `notes` +says so. An answer naming none of the three (the `y` of `yes |`) also declares +`application`, with a note naming what was heard; the user edits the file to +declare another. The question is the last one the install asks, after the +status-line offer. A declaration that is present but +refused by the reader the launch verbs share (an unknown kind, a malformed file) +is an `artefact.invalid` gap instead: the file is the user's, so the install +reports it and never overwrites it. + **The status-line offer.** When the harness's user-level settings file exists (`$CLAUDE_CONFIG_DIR/settings.json`, or `~/.claude/settings.json`) and its `statusLine` is absent or is a command that is not abcd's, the install asks diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index a3c7582cb..dce3ce123 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -171,6 +171,9 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) ac.stepRules() ac.stepVersionStamp() ac.stepIdentityPin() + // After every step that asks its questions first: the kind is the last + // answer a piped install gives (itd-2609150819432059). + ac.stepArtefact() // Last, because it describes the entry the steps above actually wrote. ac.noteReachability() diff --git a/internal/core/ahoy/artefact.go b/internal/core/ahoy/artefact.go new file mode 100644 index 000000000..5d1b6d5f3 --- /dev/null +++ b/internal/core/ahoy/artefact.go @@ -0,0 +1,151 @@ +package ahoy + +// artefact.go — the artefact declaration's adoption home +// (itd-2609150819432059, decision 3). +// +// A repository abcd manages declares what it ships in +// .abcd/config/artefact.json, and the launch verbs read that declaration. Its +// absence is an ahoy gap: `ahoy install` asks for the kind and writes the file, +// and a repository carrying a plugin manifest adopts kind plugin without being +// asked, so the shipped shape adopts silently. The file is validated by the one +// reader in internal/core/launch that every launch verb goes through, so a kind +// ahoy writes is a kind launch accepts. + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/intentdriven/abcd/internal/core/launch" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/termsafe" +) + +const ( + // ArtefactMissingGapID is the gap a repository with no declaration raises. + ArtefactMissingGapID = "artefact.missing" + // ArtefactInvalidGapID is the diagnostic for a declaration that is present + // and wrong. It is the user's data, so install reports it and never + // overwrites it. + ArtefactInvalidGapID = "artefact.invalid" + // artefactKindKey is the prompt key the kind is asked under. + artefactKindKey = "artefact_kind" + // artefactKindDefault is the answer an unanswered prompt takes: the kind + // that assumes least about the build, gate plumbing and an empty build job. + artefactKindDefault = string(launch.KindApplication) + // pluginManifestRelPath is the manifest whose presence adopts kind plugin. + pluginManifestRelPath = ".claude-plugin/plugin.json" +) + +// detectArtefact reports the declaration's state as a gap: none when it is +// present and valid, artefact.missing when absent, artefact.invalid when the one +// reader refuses it. +func detectArtefact(cwd string) []Gap { + _, err := launch.LoadArtefact(cwd) + switch { + case err == nil: + return nil + case errors.Is(err, launch.ErrNoArtefact): + detail := "this repository has not declared what it ships, so the launch verbs cannot choose what to preview, check and scaffold" + if hasPluginManifest(cwd) { + detail += "; it carries a plugin manifest, so install declares kind plugin without asking" + } + return []Gap{{ + ID: ArtefactMissingGapID, Category: ConfigChange, Scope: "repo", + Title: "artefact kind not declared (" + launch.ArtefactRelPath + ")", + Detail: detail, + FixHint: "abcd ahoy install asks for the kind (" + kindChoiceList() + ") and writes " + launch.ArtefactRelPath, + Required: true, Resolvable: true, + }} + default: + return []Gap{{ + ID: ArtefactInvalidGapID, Category: ConfigChange, Scope: "repo", + Title: "artefact declaration refused (" + launch.ArtefactRelPath + ")", + Detail: err.Error(), + FixHint: "repair " + launch.ArtefactRelPath + " by hand; install never overwrites a declaration it did not write", + Required: true, Resolvable: false, + }} + } +} + +// stepArtefact writes the declaration when the missing gap is present and +// config changes are approved: kind plugin without a question for a repository +// carrying a plugin manifest, otherwise the kind artefactKind settles. +func (a *applyCtx) stepArtefact() { + if !a.approved[ConfigChange] || !a.has(ArtefactMissingGapID) { + return + } + kind, note := a.artefactKind() + data := launch.MarshalArtefact(launch.Artefact{Kind: kind}) + // Proved through the one reader before it is written, so ahoy can never + // write a declaration launch refuses. + if _, err := launch.ParseArtefact(data); err != nil { + a.refuse("refused to write " + launch.ArtefactRelPath + ": " + err.Error()) + return + } + root, err := os.OpenRoot(a.cwd) + if err != nil { + a.refuse("could not write " + launch.ArtefactRelPath + ": " + errText(err)) + return + } + defer root.Close() + // Contained under an os.Root opened at the repository, as the identity pin + // is: a committed .abcd ancestor symlink is refused rather than followed. + if err := fsutil.WriteFileAtomicInRoot(root, launch.ArtefactRelPath, data, 0o644); err != nil { + a.refuse("could not write " + launch.ArtefactRelPath + ": " + errText(err)) + return + } + a.note(launch.ArtefactRelPath) + if note != "" { + a.refuse(note) + } +} + +// artefactKind settles the kind to declare, and the note that says so when abcd +// chose rather than the operator. It follows the house-style question's rule +// (emDashSeverity): an unattended --yes install is not asked, and an answer +// naming no kind — the "y" a `yes |` pipe sends to every question — is not +// refused, because withholding the declaration would leave every launch verb +// refusing the repository. Both take the default, application, the kind that +// assumes least about the build, and the note says what was heard and where the +// kind lives. A bare Enter or end of input takes the default the question +// displays, which is an answer and draws no note. +func (a *applyCtx) artefactKind() (launch.ArtefactKind, string) { + if hasPluginManifest(a.cwd) { + return launch.KindPlugin, "" + } + fix := "; edit " + launch.ArtefactRelPath + " to declare another (" + kindChoiceList() + ")" + if a.autoYes { + return launch.ArtefactKind(artefactKindDefault), "the artefact kind was not asked (an unattended --yes install): " + + artefactKindDefault + " is declared" + fix + } + choices := make([]string, len(launch.ArtefactKinds)) + for i, k := range launch.ArtefactKinds { + choices[i] = string(k) + } + answer := strings.TrimSpace(a.prompter.Prompt(artefactKindKey, choices, artefactKindDefault)) + switch { + case launch.ValidArtefactKind(answer): + return launch.ArtefactKind(answer), "" + case answer == "": + return launch.ArtefactKind(artefactKindDefault), "" + } + return launch.ArtefactKind(artefactKindDefault), "the answer " + termsafe.Sanitize(strconv.Quote(answer)) + " to " + + artefactKindKey + " names none of " + kindChoiceList() + ", so " + artefactKindDefault + " is declared" + fix +} + +// hasPluginManifest reports whether the repository carries a plugin manifest. +func hasPluginManifest(cwd string) bool { + info, err := os.Lstat(filepath.Join(cwd, filepath.FromSlash(pluginManifestRelPath))) + return err == nil && info.Mode().IsRegular() +} + +func kindChoiceList() string { + names := make([]string, len(launch.ArtefactKinds)) + for i, k := range launch.ArtefactKinds { + names[i] = string(k) + } + return strings.Join(names, ", ") +} diff --git a/internal/core/ahoy/artefact_test.go b/internal/core/ahoy/artefact_test.go new file mode 100644 index 000000000..1e06f7cee --- /dev/null +++ b/internal/core/ahoy/artefact_test.go @@ -0,0 +1,208 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/launch" +) + +// itd-2609150819432059 decision 3: a managed repository with no artefact +// declaration is an ahoy gap that `ahoy install` prompts for and writes; a +// plugin repository adopts kind plugin without being asked. + +// recordingStub answers like stubPrompter and records every prompt key asked. +type recordingStub struct { + stubPrompter + asked *[]string +} + +func (r recordingStub) Prompt(key string, choices []string, def string) string { + *r.asked = append(*r.asked, key) + return r.stubPrompter.Prompt(key, choices, def) +} + +func artefactRepo(t *testing.T) string { + t.Helper() + setupHermetic(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + return repo +} + +func artefactGap(gaps []Gap, id string) (Gap, bool) { + for _, g := range gaps { + if g.ID == id { + return g, true + } + } + return Gap{}, false +} + +func TestDetectRaisesArtefactMissingUntilTheKindIsDeclared(t *testing.T) { + repo := artefactRepo(t) + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + g, ok := artefactGap(det.Gaps, ArtefactMissingGapID) + if !ok || !g.Required || !g.Resolvable || g.Category != ConfigChange || g.Scope != "repo" { + t.Fatalf("gap %+v (present %v), want a required, resolvable repo config-change gap", g, ok) + } + artefactWrite(t, repo, launch.ArtefactRelPath, `{"kind": "binary"}`) + det, err = Detect(repo) + if err != nil { + t.Fatal(err) + } + if _, ok := artefactGap(det.Gaps, ArtefactMissingGapID); ok { + t.Error("a declared kind still raises the missing gap") + } +} + +// A declaration that is present and wrong is the user's data: a diagnostic gap +// naming the fault, which install does not overwrite. +func TestDetectReportsAnInvalidDeclarationWithoutClaimingToFixIt(t *testing.T) { + repo := artefactRepo(t) + artefactWrite(t, repo, launch.ArtefactRelPath, `{"kind": "wheel"}`) + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + g, ok := artefactGap(det.Gaps, ArtefactInvalidGapID) + if !ok || g.Resolvable || !g.Required { + t.Fatalf("gap %+v (present %v), want a required, non-resolvable diagnostic", g, ok) + } + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + if got := artefactRead(t, repo, launch.ArtefactRelPath); got != `{"kind": "wheel"}` { + t.Errorf("install overwrote the user's declaration: %s", got) + } +} + +func TestInstallAdoptsKindPluginSilentlyForAPluginRepository(t *testing.T) { + repo := artefactRepo(t) + artefactWrite(t, repo, ".claude-plugin/plugin.json", `{"name": "fixture"}`) + var asked []string + p := recordingStub{stubPrompter{confirm: true}, &asked} + if _, err := Install(repo, installOpts(), p); err != nil { + t.Fatal(err) + } + art, err := launch.LoadArtefact(repo) + if err != nil || art.Kind != launch.KindPlugin { + t.Fatalf("declaration %+v, err %v; want kind plugin", art, err) + } + for _, k := range asked { + if k == artefactKindKey { + t.Error("a plugin repository was asked its kind") + } + } +} + +func TestInstallPromptsForTheKindAndWritesIt(t *testing.T) { + repo := artefactRepo(t) + var asked []string + p := recordingStub{stubPrompter{confirm: true, answers: map[string]string{artefactKindKey: "binary"}}, &asked} + opts := installOpts() + opts.Yes = false + res, err := Install(repo, opts, p) + if err != nil { + t.Fatal(err) + } + art, err := launch.LoadArtefact(repo) + if err != nil || art.Kind != launch.KindBinary { + t.Fatalf("declaration %+v, err %v; want kind binary (result %+v)", art, err, res) + } + seen := false + for _, k := range asked { + seen = seen || k == artefactKindKey + } + if !seen { + t.Errorf("the kind was never asked: %v", asked) + } + // The written file reads back through the one reader and re-running is a + // no-op for it. + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if _, ok := artefactGap(det.Gaps, ArtefactMissingGapID); ok { + t.Error("the gap survived the install that answered it") + } +} + +// An answer naming no kind — the "y" a `yes |` pipe sends — declares the +// default and says what was heard, as the house-style question does, rather +// than leaving every launch verb refusing the repository. +func TestInstallDeclaresTheDefaultOnAnAnswerOutsideTheSet(t *testing.T) { + repo := artefactRepo(t) + var asked []string + p := recordingStub{stubPrompter{confirm: true, answers: map[string]string{artefactKindKey: "wheel"}}, &asked} + opts := installOpts() + opts.Yes = false + res, err := Install(repo, opts, p) + if err != nil { + t.Fatal(err) + } + art, err := launch.LoadArtefact(repo) + if err != nil || art.Kind != launch.KindApplication { + t.Fatalf("declaration %+v, err %v; want the default, application", art, err) + } + noted := false + for _, n := range res.Notes { + noted = noted || (strings.Contains(n, `"wheel"`) && strings.Contains(n, "plugin, binary, application")) + } + if !noted { + t.Errorf("the note does not say what was heard: %v", res.Notes) + } +} + +// An unattended --yes install is not asked: it declares the default and says so. +func TestInstallYesDeclaresTheDefaultWithoutAsking(t *testing.T) { + repo := artefactRepo(t) + var asked []string + res, err := Install(repo, installOpts(), recordingStub{stubPrompter{confirm: true}, &asked}) + if err != nil { + t.Fatal(err) + } + for _, k := range asked { + if k == artefactKindKey { + t.Error("--yes asked the artefact kind") + } + } + art, err := launch.LoadArtefact(repo) + if err != nil || art.Kind != launch.KindApplication { + t.Fatalf("declaration %+v, err %v; want the default, application", art, err) + } + noted := false + for _, n := range res.Notes { + noted = noted || strings.Contains(n, "--yes") + } + if !noted { + t.Errorf("the default was declared without a note: %v", res.Notes) + } +} + +func artefactWrite(t *testing.T, repo, rel, content string) { + t.Helper() + abs := filepath.Join(repo, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func artefactRead(t *testing.T, repo, rel string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index b59f3bdf9..92a3e8958 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -115,6 +115,9 @@ func Detect(cwd string) (DetectionResult, error) { // The attribution prompt is opt-in, so this reports nothing at all for a repo // that never adopted it — and a hand-deleted hook for one that did. gaps = append(gaps, detectAttributionHook(abs)...) + // What the repository ships, declared once for the launch verbs + // (itd-2609150819432059). + gaps = append(gaps, detectArtefact(abs)...) } sortGaps(gaps) From e8016107975fdb655d2366b6f57f05a3e4efa0ca Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:51:39 +0100 Subject: [PATCH 053/107] =?UTF-8?q?chore:=20resolve=20iss-2608270559310755?= =?UTF-8?q?=20=E2=80=94=20the=20scaffold=20builds=20nothing=20it=20guessed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608270559310755 Assisted-by: Claude:claude-opus-5-5 --- ...fold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md (64%) diff --git a/.abcd/work/issues/open/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md b/.abcd/work/issues/resolved/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md similarity index 64% rename from .abcd/work/issues/open/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md rename to .abcd/work/issues/resolved/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md index d5fe7d618..f0bb2036d 100644 --- a/.abcd/work/issues/open/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md +++ b/.abcd/work/issues/resolved/iss-2608270559310755-launch-scaffold-s-release-yml-tmpl-is-hardcoded-to-abcd-cli.md @@ -7,6 +7,14 @@ category: "bug" source: "user-observation" found_during: "testimony-launch-scaffold-2026-08-27" found_at: "internal/core/launch/scaffold/templates/release.yml.tmpl" +resolution: "launch scaffold no longer hard-codes a build: a managed repository's release workflow (and the non-plugin gate workflow) carries gate plumbing plus a named empty build job whose one step the repository fills, naming dist/ as the output the publish job attaches; the verify job's Go leg renders only for a go.mod; TestBareReleaseIsGatePlumbingPlusANamedEmptyBuildJob renders both profiles and the end-to-end run publishes through the empty job" +impact: fix +resolved_by: + commit: "ee2ad0f1" --- -launch scaffold's release.yml.tmpl is hardcoded to abcd-cli's OWN artifact shape, so it calls itself a 'generic bare-repo' workflow but is not generic: it runs 'make build' to cross-compile 'the four binaries', 'sha256sum abcd-* > checksums.txt', and 'gh release create <tag> bin/abcd-* bin/checksums.txt' as literals (only the verify/Run step is a <% %> substitution). A scaffolded repo that is not a Go CLI producing four abcd-* binaries via make build therefore gets a release workflow that builds/publishes the wrong (or no matching) assets, and its install path expects artifacts the release never produces. This is the confirmed 'asset gap' seen in a scaffolded non-abcd-cli repo (its install expected four tarballs + SHA256SUMS the workflow does not publish). Fix requires a product decision on how a non-abcd-cli repo declares/derives its build+artifact shape (parameterise the build command / artifact glob / checksum name via render.go substitutions with a repo-declared shape; or detect Go-CLI vs other; or scaffold only the generic release plumbing by default and make binary build/publish opt-in). Neighbour iss-2608261041218890 (release.yml tag not shape-checked before make build) is a different defect in the same template. Do NOT touch abcd-cli's own .github/workflows/release.yml. \ No newline at end of file +launch scaffold's release.yml.tmpl is hardcoded to abcd-cli's OWN artifact shape, so it calls itself a 'generic bare-repo' workflow but is not generic: it runs 'make build' to cross-compile 'the four binaries', 'sha256sum abcd-* > checksums.txt', and 'gh release create <tag> bin/abcd-* bin/checksums.txt' as literals (only the verify/Run step is a <% %> substitution). A scaffolded repo that is not a Go CLI producing four abcd-* binaries via make build therefore gets a release workflow that builds/publishes the wrong (or no matching) assets, and its install path expects artifacts the release never produces. This is the confirmed 'asset gap' seen in a scaffolded non-abcd-cli repo (its install expected four tarballs + SHA256SUMS the workflow does not publish). Fix requires a product decision on how a non-abcd-cli repo declares/derives its build+artifact shape (parameterise the build command / artifact glob / checksum name via render.go substitutions with a repo-declared shape; or detect Go-CLI vs other; or scaffold only the generic release plumbing by default and make binary build/publish opt-in). Neighbour iss-2608261041218890 (release.yml tag not shape-checked before make build) is a different defect in the same template. Do NOT touch abcd-cli's own .github/workflows/release.yml. + +## Grounds + +- pursued: we expect a scaffolded managed repository to publish whatever its own build leaves in dist/ and nothing abcd guessed; shown wrong if a rendered bare or gate workflow carries make build, an abcd-* asset or a go build in its publish path, or if the end-to-end runner fails to publish a first release through the empty build job From b52746fb33497221fbb17cfd81d08088bb56e752 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:51:49 +0100 Subject: [PATCH 054/107] =?UTF-8?q?chore:=20close=20spc-2609202019026366?= =?UTF-8?q?=20=E2=80=94=20a=20managed=20repository=20that=20is=20not=20a?= =?UTF-8?q?=20plugin=20gets=20the=20release=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the spec with impact additive (the intent declares none), which ships itd-2609150819432059 and repoints the three links that named its planned path. Delivers: itd-2609150819432059 Assisted-by: Claude:claude-opus-5-5 --- ...ecisions-log-is-a-folder-of-minted-records-not-one-appe.md | 2 +- ...end-only-logs-conflict-on-every-merge-in-a-managed-repo.md | 4 ++-- ...launch-cannot-set-up-the-release-flow-for-a-managed-rep.md | 4 +++- ...launch-cannot-set-up-the-release-flow-for-a-managed-rep.md | 0 4 files changed, 6 insertions(+), 4 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md (98%) rename .abcd/development/specs/{open => closed}/spc-2609202019026366-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md (100%) diff --git a/.abcd/development/decisions/adrs/2609151138420062-the-decisions-log-is-a-folder-of-minted-records-not-one-appe.md b/.abcd/development/decisions/adrs/2609151138420062-the-decisions-log-is-a-folder-of-minted-records-not-one-appe.md index a04f89dc4..ddae6de5d 100644 --- a/.abcd/development/decisions/adrs/2609151138420062-the-decisions-log-is-a-folder-of-minted-records-not-one-appe.md +++ b/.abcd/development/decisions/adrs/2609151138420062-the-decisions-log-is-a-folder-of-minted-records-not-one-appe.md @@ -207,7 +207,7 @@ criteria. deriving the changelog from records at the cut and refusing a non-empty `## [Unreleased]`; what a managed repository lacks is the ability to use that flow, which is - [itd-2609150819432059](../../intents/planned/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md). + [itd-2609150819432059](../../intents/shipped/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md). Neither record refines the other; both conflict classes close only when both land. - **The union attribute for `CHANGELOG.md` stays where it is.** This record diff --git a/.abcd/development/intents/drafts/itd-2609151138388536-append-only-logs-conflict-on-every-merge-in-a-managed-repo.md b/.abcd/development/intents/drafts/itd-2609151138388536-append-only-logs-conflict-on-every-merge-in-a-managed-repo.md index 4332e680b..491d713d8 100644 --- a/.abcd/development/intents/drafts/itd-2609151138388536-append-only-logs-conflict-on-every-merge-in-a-managed-repo.md +++ b/.abcd/development/intents/drafts/itd-2609151138388536-append-only-logs-conflict-on-every-merge-in-a-managed-repo.md @@ -16,7 +16,7 @@ related_adrs: [adr-2609151138420062] # Every Decision Is Its Own Record, and the Log Stops Conflicting -Typed links: `related_adrs` [adr-2609151138420062](../../decisions/adrs/2609151138420062-the-decisions-log-is-a-folder-of-minted-records-not-one-appe.md) (the shape rule this intent enacts, taken in the same change); `refines` [adr-45](../../decisions/adrs/0045-record-ids-are-timestamp-numeric-and-capture-stable.md) (the timestamp-numeric id seam, which this adds a sixth family to and changes in no way); `related_issues` [iss-2609100507439414](../../../work/issues/open/iss-2609100507439414-append-only-logs-conflict-on-every-merge-in-a-managed-repo.md) (the negative finding: the append-only log conflicted on the first two of 27 branch merges). Prose cross-references, not typed links, because no schema field carries the relation ([iss-2609091256264547](../../../work/issues/open/iss-2609091256264547-three-of-the-four-mandated-typed-relations-cannot-be-written.md)): [itd-2609150819432059](../planned/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md), the companion that closes the OTHER conflict class — this intent refines nothing of its, and it refines nothing of this intent's, but the two together close both classes and neither closes both alone; and [iss-2609100508570803](../../../work/issues/resolved/iss-2609100508570803-the-record-verbs-worked-from-worktrees-throughout-the-run.md), the positive finding from the same run that makes this a shape question — five one-file-per-entry record families, zero conflicts, same 27 merges. +Typed links: `related_adrs` [adr-2609151138420062](../../decisions/adrs/2609151138420062-the-decisions-log-is-a-folder-of-minted-records-not-one-appe.md) (the shape rule this intent enacts, taken in the same change); `refines` [adr-45](../../decisions/adrs/0045-record-ids-are-timestamp-numeric-and-capture-stable.md) (the timestamp-numeric id seam, which this adds a sixth family to and changes in no way); `related_issues` [iss-2609100507439414](../../../work/issues/open/iss-2609100507439414-append-only-logs-conflict-on-every-merge-in-a-managed-repo.md) (the negative finding: the append-only log conflicted on the first two of 27 branch merges). Prose cross-references, not typed links, because no schema field carries the relation ([iss-2609091256264547](../../../work/issues/open/iss-2609091256264547-three-of-the-four-mandated-typed-relations-cannot-be-written.md)): [itd-2609150819432059](../shipped/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md), the companion that closes the OTHER conflict class — this intent refines nothing of its, and it refines nothing of this intent's, but the two together close both classes and neither closes both alone; and [iss-2609100508570803](../../../work/issues/resolved/iss-2609100508570803-the-record-verbs-worked-from-worktrees-throughout-the-run.md), the positive finding from the same run that makes this a shape question — five one-file-per-entry record families, zero conflicts, same 27 merges. ## Press Release @@ -74,7 +74,7 @@ changelog, whose `[Unreleased]` section has the same shape and where union is itself by deriving the changelog from records at the cut and refusing a non-empty `## [Unreleased]`; what a managed repository lacks is the ability to use that flow, which is -[itd-2609150819432059](../planned/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md). +[itd-2609150819432059](../shipped/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md). This intent `refines` nothing of itd-2609150819432059's, and itd-2609150819432059 `refines` nothing of this one's: they are disjoint. Together they close both conflict classes, and neither closes both alone. The typed link is a companion diff --git a/.abcd/development/intents/planned/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md b/.abcd/development/intents/shipped/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md similarity index 98% rename from .abcd/development/intents/planned/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md rename to .abcd/development/intents/shipped/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md index f95252e44..2f6b4bd55 100644 --- a/.abcd/development/intents/planned/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md +++ b/.abcd/development/intents/shipped/itd-2609150819432059-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md @@ -10,6 +10,7 @@ severity: major related_issues: [iss-2609061432214212] origin: extracted-from-record production_mode: hand-written +impact: additive --- # A managed repository that is not a plugin gets the same release gate @@ -76,7 +77,8 @@ Settled in the planning interview with the product thinker on 2026-09-20; each r ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ +<!-- abcd-review: OWED receipt=rcp-97519c4308ad --> +Fidelity review OWED (receipt rcp-97519c4308ad). ## Grounds diff --git a/.abcd/development/specs/open/spc-2609202019026366-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md b/.abcd/development/specs/closed/spc-2609202019026366-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md similarity index 100% rename from .abcd/development/specs/open/spc-2609202019026366-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md rename to .abcd/development/specs/closed/spc-2609202019026366-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md From 4795871fb1270d1b279dc8112af14ecc6c79d3f7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:51:56 +0100 Subject: [PATCH 055/107] =?UTF-8?q?chore:=20resolve=20iss-2609061432214212?= =?UTF-8?q?=20=E2=80=94=20the=20release=20flow=20reaches=20a=20managed=20r?= =?UTF-8?q?epository=20that=20is=20not=20a=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609061432214212 Assisted-by: Claude:claude-opus-5-5 --- ...unch-cannot-set-up-the-release-flow-for-a-managed-rep.md | 6 ++++++ 1 file changed, 6 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md (81%) diff --git a/.abcd/work/issues/open/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md b/.abcd/work/issues/resolved/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md similarity index 81% rename from .abcd/work/issues/open/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md rename to .abcd/work/issues/resolved/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md index 2c05466de..11c42f04c 100644 --- a/.abcd/work/issues/open/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md +++ b/.abcd/work/issues/resolved/iss-2609061432214212-abcd-launch-cannot-set-up-the-release-flow-for-a-managed-rep.md @@ -12,6 +12,10 @@ deferred_after: "v0.8.0" deferral_reason: "This asks abcd to set up the release flow for a repository it manages, which is a capability rather than a defect. What the release flow should assume about a managed artefact that is not a plugin, what it should scaffold, and what it should refuse to guess are product questions, and the record lists them as open. One symptom is fixed in this cut: a repository declaring no plugin manifest no longer refuses at an unconditional manifest read, so the changelog verb gives an honest verdict where it previously died. The rest wants the capability designed rather than inferred." found_at: "internal (launch, changelog, scaffold)" related_intents: [itd-2609150819432059] +resolution: "itd-2609150819432059 shipped: a managed repository declares its artefact kind in .abcd/config/artefact.json (ahoy install writes it), launch --dry-run and launch ship run against the declared kind with the changelog-driven gate, the deferral read and the derived version unchanged, and launch scaffold lays the kind's files, leaving a repository's own release workflow byte-for-byte and printing the job that calls the gate" +impact: additive +resolved_by: + commit: "b52746fb" --- abcd launch cannot set up the release flow for a managed repo that is not a plugin. Observed adopting abcd in a managed Go macOS app (own tag-driven release workflow building on a macOS runner with minisign, no CHANGELOG.md, no .claude-plugin/plugin.json): 'abcd launch --dry-run' fails with 'include config not found: .abcd/config/launch-payload.json'; 'abcd changelog' and 'abcd launch ship' fail reading .claude-plugin/plugin.json; 'abcd launch scaffold' writes the generic ubuntu Go template (verify/build/publish) that replaces the repo's own release workflow, and nothing creates the pieces the flow presupposes: CHANGELOG.md with its empty [Unreleased] anchor, the launch payload include config, a version location for a non-plugin artefact, and (where detectors are configured) the release-gate manifest and receipts directory. Needed: an adoption step (ahoy install or launch scaffold --init) that lays these down for a managed repo, a way to declare a non-plugin version location and payload, and a template extension point for platform-specific build/publish steps so scaffold parity does not fight a macOS build. Until then a managed repo has to hand-port the template. @@ -33,3 +37,5 @@ open major captured since the anchor tag unless deferred) therefore never ran there; the session enforced it by reading the ledger. The ask is the one this record already carries: the launch verbs for a managed repository that is not a plugin, or a `launch scaffold` that lays the missing include and says so. + +- pursued: we expect a managed Go application or macOS app to cut its next release through launch --dry-run and launch ship with the findings gate enforced by the binary rather than by a session reading the ledger; shown wrong if either managed repository still cuts by hand, or if a kind needs a gate input no declaration can supply From 256dff99974844eb01a7ac65c2d9ee13d76c8be2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:57:17 +0100 Subject: [PATCH 056/107] chore: capture the ahoy review's copy fixes and follow-ups Eight findings from review 1 of the ahoy lane, captured before they are fixed so each fix has a record to resolve. Refs: iss-2609260057117878, iss-2609260057112822, iss-2609260057117838, iss-2609260057111298, iss-2609260057112155, iss-2609260057111315, iss-2609260057127611, iss-2609260057123452 Assisted-by: Claude:claude-opus-5-5 --- ...iles-missing-state-is-worded-two-ways-in-one.md | 14 ++++++++++++++ ...fuses-a-world-writable-plugin-data-directory.md | 14 ++++++++++++++ ...outroot-s-refusal-for-a-repo-shaped-tree-git.md | 14 ++++++++++++++ ...plain-labels-a-write-that-carries-no-kind-as.md | 14 ++++++++++++++ ...s-foreign-refusal-for-a-path-entry-that-is-a.md | 14 ++++++++++++++ ...mary-s-plain-language-copy-says-three-untrue.md | 14 ++++++++++++++ ...r-note-every-capture-verb-prints-reports-the.md | 14 ++++++++++++++ ...nstall-writes-still-swallow-their-errors-the.md | 14 ++++++++++++++ 8 files changed, 112 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md create mode 100644 .abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md create mode 100644 .abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md create mode 100644 .abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md create mode 100644 .abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md create mode 100644 .abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md create mode 100644 .abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md create mode 100644 .abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md diff --git a/.abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md b/.abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md new file mode 100644 index 000000000..6b300decb --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057111298" +slug: "the-plugin-files-missing-state-is-worded-two-ways-in-one" +severity: "nitpick" +category: "inconsistency" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/guard_health.go" +--- + +The plugin-files-missing state is worded two ways in one abcd ahoy render: the plugin.root_missing gap speaks plainly, but the guard-health reason still says: plugin root not resolvable, so the hook manifest cannot be read and the guard wiring is unknown. diff --git a/.abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md b/.abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md new file mode 100644 index 000000000..040ba5779 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057111315" +slug: "datadirhazard-refuses-a-world-writable-plugin-data-directory" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/data_dir.go" +--- + +dataDirHazard refuses a world-writable plugin data directory or cache subdirectory, but not a group-writable one or one owned by another uid, so on a shared host a group member or another account can still supply the cache artefact and its recorded hash the hazard check claims to rule out. It should apply the caller-alone test the home-scoped declarations use (not writable by group or other, owned by this uid). diff --git a/.abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md b/.abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md new file mode 100644 index 000000000..1f72d3726 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057112155" +slug: "gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git" +severity: "minor" +category: "documentation" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/gitutil/repo.go" +--- + +gitutil.CheckoutRoot's refusal for a repo-shaped tree git will not answer for names three causes (git absent from PATH, the repository unreadable, its ownership refused) and omits the fourth that Toplevel's shape check refuses: a core.worktree setting whose working tree does not contain the working directory. A person in that checkout is offered three causes, none of which is theirs. diff --git a/.abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md b/.abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md new file mode 100644 index 000000000..04d4b45b5 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057112822" +slug: "installresult-explain-labels-a-write-that-carries-no-kind-as" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/install_summary.go" +--- + +InstallResult.explain labels a write that carries no kind as the optional-scanner hint (its fallback is writeScannerHint), so an unkinded write would be explained as something it is not, and a kind with no entry in allWriteKinds drops out of the summary entirely. The fallback should be an honest unexplained-write item. diff --git a/.abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md b/.abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md new file mode 100644 index 000000000..9b11cd258 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057117838" +slug: "abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/update/update.go" +--- + +abcd update's foreign refusal, for a PATH entry that is a symlink, reads: the entry at X leads to Y, which is not ... a regular file abcd can verify. But Y is a regular file (the classifier Lstat'd the entry, not Y), so the person is told something false about the link's target. The negatives belong to the entry. diff --git a/.abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md b/.abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md new file mode 100644 index 000000000..caef995be --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057117878" +slug: "the-install-summary-s-plain-language-copy-says-three-untrue" +severity: "minor" +category: "documentation" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/install_summary.go" +--- + +The install summary's plain-language copy says three untrue things. The identity-pin item tells a person with a wrong recorded name or email to run abcd ahoy install again, but stepIdentityPin writes only while no pin exists, so a re-run changes nothing; the skipped-pin item says to answer y if the name and email shown are yours, but nothing shows a name or email (the pin rides on the settings confirm); and the public visibility meaning says only .abcd/ is kept out of git, omitting the /memory/ fence the public block also writes (gitignore.go visibilityEntries). diff --git a/.abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md b/.abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md new file mode 100644 index 000000000..8fedb1859 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057123452" +slug: "the-stray-ledger-note-every-capture-verb-prints-reports-the" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/cli.go" +--- + +The stray-ledger note every capture verb prints reports the checkout's own ledger as a second ledger when the working directory is a case-variant spelling of the checkout on a case-insensitive filesystem (the APFS default): strayStoreNotes compares EvalSymlinks strings, which keep the caller's case, so the walk never meets the root and names ../<root>/.abcd/work/issues as a ledger below the root. diff --git a/.abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md b/.abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md new file mode 100644 index 000000000..f3b22ceda --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260057127611" +slug: "three-ahoy-install-writes-still-swallow-their-errors-the" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/apply.go" +--- + +Three ahoy install writes still swallow their errors, the class iss-227 fixed for their siblings: the identity pin (stepIdentityPin drops a failed identity.WritePin, and an unset git identity, without a word), the .gitignore visibility block (stepVisibility drops applyVisibilityBlock's error), and the conventions-file marker block (stepMarker drops installMarkerFile and removeMarkerFile failures). A run that wrote none of them reports with no reason. From d44954d5c5b5c2cd43a0f9b651aada92b80e5254 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:58:36 +0100 Subject: [PATCH 057/107] fix(ahoy): the install summary's copy says only what is true Three sentences a person acts on were untrue. A wrong recorded name or email is not corrected by re-running the install, which leaves an existing pin as it is, so the item names the file that corrects it. The skipped-pin item promised a name and email the install never shows, so it says where the values come from and names the config-change question the pin rides on. The public visibility meaning omitted the memory/ fence its .gitignore block writes, so each meaning now names every path its block keeps out of git, and a test derives that from the entry table. Refs: iss-2609260057117878 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/install_summary.go | 4 +- .../core/ahoy/install_summary_truth_test.go | 69 +++++++++++++++++++ internal/core/ahoy/prompt_help.go | 7 +- 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 internal/core/ahoy/install_summary_truth_test.go diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index e08c35071..a65e0f724 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -102,7 +102,7 @@ var writeKindHelp = map[writeKind]SummaryItem{ writeIdentityPin: { What: "Recorded the git name and email that commit to this repository.", Why: "abcd can then warn when a commit is about to be made under a different identity, such as an agent's.", - Action: "Nothing, unless the recorded name or email is wrong; then run abcd ahoy install again.", + Action: "Nothing, unless the recorded name or email is wrong; then correct it in .abcd/config/identity.json, because running abcd ahoy install again leaves a recorded name and email as they are.", }, writeCommandEntry: { What: "Made the abcd command available in your terminal.", @@ -175,7 +175,7 @@ var optionalSkippedHelp = map[string]SummaryItem{ OptionalPinGapID: { What: "Recording who commits to this repository was left for you to confirm.", Why: "It would record whatever git name and email happen to be set, which in an unattended run may be an agent's.", - Action: "Run abcd ahoy install without --yes and answer y if the name and email shown are yours.", + Action: "Check that git config user.name and git config user.email give your own name and email, then run abcd ahoy install without --yes and answer y to the config-change question; abcd records those two values.", }, StatusLineOfferGapID: { What: "abcd's status line was not set up.", diff --git a/internal/core/ahoy/install_summary_truth_test.go b/internal/core/ahoy/install_summary_truth_test.go new file mode 100644 index 000000000..9b9b79604 --- /dev/null +++ b/internal/core/ahoy/install_summary_truth_test.go @@ -0,0 +1,69 @@ +package ahoy + +import ( + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/identity" +) + +// TestIdentityPinActionNamesWhatCorrectsAWrongPin is iss-2609260057117878's +// first sentence: the summary told a person whose recorded name or email is +// wrong to run abcd ahoy install again, but the pin step writes only while no +// pin exists, so a re-run leaves a wrong pin exactly as it is. The behaviour is +// proved here first, and then the item is held to naming the file that does +// correct it. +func TestIdentityPinActionNamesWhatCorrectsAWrongPin(t *testing.T) { + dir := idGitRepo(t, "Right Person", "right@example.com") + idWritePin(t, dir, `{"name":"Wrong Person","email":"wrong@example.com"}`) + gapPresent := map[string]bool{} + for _, g := range detectGitIdentity(dir) { + gapPresent[g.ID] = true + } + a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{ConfigChange: true}, gapPresent: gapPresent} + a.stepIdentityPin() + pin, ok, err := identity.LoadPin(dir) + if err != nil || !ok || pin.Name != "Wrong Person" { + t.Fatalf("precondition: a re-run leaves a recorded pin as it is; got %+v ok=%v err=%v", pin, ok, err) + } + + action := writeKindHelp[writeIdentityPin].Action + if !strings.Contains(action, identity.PinRelPath) { + t.Errorf("the identity-pin item must name the file that corrects a wrong pin (%s), since a re-run does not: %q", identity.PinRelPath, action) + } +} + +// TestSkippedPinActionNamesWhatThePersonChecks is the second sentence: the +// item told the person to answer y "if the name and email shown are yours", +// but the install shows no name or email; the pin rides on the question about +// the category it belongs to. The item must say where the values come from and +// name the question that is actually asked. +func TestSkippedPinActionNamesWhatThePersonChecks(t *testing.T) { + action := optionalSkippedHelp[OptionalPinGapID].Action + for _, want := range []string{"user.name", "user.email", string(ConfigChange)} { + if !strings.Contains(action, want) { + t.Errorf("the skipped-pin item does not name %q: %q", want, action) + } + } + if strings.Contains(action, "shown") { + t.Errorf("the skipped-pin item promises values the install never shows: %q", action) + } +} + +// TestVisibilityMeaningNamesEveryFencedPath is the third: each visibility's +// meaning must name every path its .gitignore block keeps out of git, so the +// public meaning cannot again omit the memory/ fence the block writes. +func TestVisibilityMeaningNamesEveryFencedPath(t *testing.T) { + for _, c := range promptHelp["visibility"].Choices { + entries, ok := visibilityEntries[c.Value] + if !ok { + t.Fatalf("visibility choice %q has no entry set", c.Value) + } + for _, e := range entries { + bare := strings.Trim(e, "/") + if !strings.Contains(c.Meaning, bare) { + t.Errorf("the %s meaning does not name %s, which its .gitignore block keeps out of git: %q", c.Value, e, c.Meaning) + } + } + } +} diff --git a/internal/core/ahoy/prompt_help.go b/internal/core/ahoy/prompt_help.go index b42efdc4f..7bf13c19f 100644 --- a/internal/core/ahoy/prompt_help.go +++ b/internal/core/ahoy/prompt_help.go @@ -56,9 +56,10 @@ var promptHelp = map[string]PromptHelp{ "are committed with your code or kept out of git. It decides what the block abcd writes into .gitignore contains.", Choices: []ChoiceHelp{ {Value: "private", Meaning: "the records under .abcd/ are committed with your code, so everyone who can see the repository shares them; " + - "only abcd's per-machine scratch space is kept out of git. Suits a repository whose code is not published."}, - {Value: "public", Meaning: "the whole .abcd/ folder is kept out of git, so the records stay on this machine and are not published with your code. " + - "If .abcd/ already holds committed records, only the per-machine scratch space is kept out, because git cannot hide a file it already tracks."}, + "only abcd's per-machine scratch space, .abcd/.work.local/, is kept out of git. Suits a repository whose code is not published."}, + {Value: "public", Meaning: "the whole .abcd/ folder is kept out of git, so the records stay on this machine and are not published with your code, " + + "and so is a memory/ folder at the top of the repository, the older home of abcd's memory store. " + + "If .abcd/ already holds committed records, only its per-machine scratch space is kept out, because git cannot hide a file it already tracks; the memory/ folder is still kept out."}, }, }, "docs_target": { From ba2251fedc7e782d75e5bf0e0512f9ee23e5a34e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:58:45 +0100 Subject: [PATCH 058/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057117878?= =?UTF-8?q?=20=E2=80=94=20install=20summary=20copy=20is=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057117878 Assisted-by: Claude:claude-opus-5-5 --- ...all-summary-s-plain-language-copy-says-three-untrue.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md (58%) diff --git a/.abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md b/.abcd/work/issues/resolved/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md similarity index 58% rename from .abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md rename to .abcd/work/issues/resolved/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md index caef995be..895fddab3 100644 --- a/.abcd/work/issues/open/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md +++ b/.abcd/work/issues/resolved/iss-2609260057117878-the-install-summary-s-plain-language-copy-says-three-untrue.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/install_summary.go" +resolution: "The identity-pin item names .abcd/config/identity.json as the correction, the skipped-pin item names git's user.name/user.email and the config-change question, and each visibility meaning names every path its .gitignore block fences, memory/ included." +impact: fix +resolved_by: + commit: "d44954d5" --- The install summary's plain-language copy says three untrue things. The identity-pin item tells a person with a wrong recorded name or email to run abcd ahoy install again, but stepIdentityPin writes only while no pin exists, so a re-run changes nothing; the skipped-pin item says to answer y if the name and email shown are yours, but nothing shows a name or email (the pin rides on the settings confirm); and the public visibility meaning says only .abcd/ is kept out of git, omitting the /memory/ fence the public block also writes (gitignore.go visibilityEntries). + +## Grounds + +- pursued: the three sentences match the code; TestIdentityPinActionNamesWhatCorrectsAWrongPin, TestSkippedPinActionNamesWhatThePersonChecks and TestVisibilityMeaningNamesEveryFencedPath would fail if a re-run began correcting a pin without the copy changing, if the prompt stopped naming config-change, or if a fenced path went unnamed From 47af5ce163951c9b616c290ccbaecd3187cfbaf6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:59:15 +0100 Subject: [PATCH 059/107] fix(ahoy): an unkinded install write is reported as unexplained explain() fell back to the scanner hint for a write that carried no kind, so such a write would have been described as something it is not, and a kind with no entry in the table dropped out of the summary altogether. Both now reach the person as a write the summary cannot describe, with its path listed. Every write site passes a kind today, so the fallback is unreachable in a real run; the test forces it. Refs: iss-2609260057112822 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/install_summary.go | 25 +++++++++++- .../ahoy/install_summary_fallback_test.go | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 internal/core/ahoy/install_summary_fallback_test.go diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index a65e0f724..502d8a977 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -131,6 +131,17 @@ var writeKindHelp = map[writeKind]SummaryItem{ }, } +// unexplainedWriteHelp is what a write reaches the person as when it carries no +// kind, or a kind this table has no entry for. Every write site passes a kind +// today, so it is a fallback that should never be seen; if it is, it must say +// plainly that the summary cannot describe the write, rather than borrow another +// kind's explanation, and it must still list the path. +var unexplainedWriteHelp = SummaryItem{ + What: "Wrote a file this summary has no plain description for.", + Why: "abcd lists every file it writes, so none is left out even when it cannot say what the file is for.", + Action: "Look at the file listed; abcd ahoy doctor shows what abcd expects to find in this repository.", +} + // declinedCategoryHelp explains each kind of change the person declined. var declinedCategoryHelp = map[GapCategory]SummaryItem{ SafeAutocreate: { @@ -217,11 +228,18 @@ func (r *InstallResult) explain() { r.Summary = []SummaryItem{} refs := map[writeKind][]string{} + var unexplained []string for i, w := range r.Writes { - k := writeScannerHint + var k writeKind if i < len(r.writeKinds) { k = r.writeKinds[i] } + if _, known := writeKindHelp[k]; !known || !slices.Contains(allWriteKinds, k) { + if !slices.Contains(unexplained, w) { + unexplained = append(unexplained, w) + } + continue + } if !slices.Contains(refs[k], w) { refs[k] = append(refs[k], w) } @@ -234,6 +252,11 @@ func (r *InstallResult) explain() { it.Refs = refs[k] r.Summary = append(r.Summary, it) } + if len(unexplained) > 0 { + it := unexplainedWriteHelp + it.Refs = unexplained + r.Summary = append(r.Summary, it) + } for _, c := range r.DeclinedCategories { it, ok := declinedCategoryHelp[GapCategory(c)] if !ok { diff --git a/internal/core/ahoy/install_summary_fallback_test.go b/internal/core/ahoy/install_summary_fallback_test.go new file mode 100644 index 000000000..22c72023d --- /dev/null +++ b/internal/core/ahoy/install_summary_fallback_test.go @@ -0,0 +1,38 @@ +package ahoy + +import "testing" + +// TestUnkindedWriteIsExplainedAsUnexplained is iss-2609260057112822: a write +// that carries no kind, or a kind the table has no entry for, must reach the +// person as a write the summary cannot describe, never as the scanner hint (the +// old fallback) and never not at all. +func TestUnkindedWriteIsExplainedAsUnexplained(t *testing.T) { + r := &InstallResult{ + Writes: []string{"/x/kinded", "/x/unknown-kind", "/x/no-kind"}, + writeKinds: []writeKind{writeRules, writeKind("no-such-kind")}, + } + r.explain() + byRef := map[string]SummaryItem{} + for _, it := range r.Summary { + for _, ref := range it.Refs { + byRef[ref] = it + } + } + if byRef["/x/kinded"].What != writeKindHelp[writeRules].What { + t.Errorf("a kinded write lost its explanation: %+v", byRef["/x/kinded"]) + } + for _, w := range []string{"/x/unknown-kind", "/x/no-kind"} { + it, ok := byRef[w] + if !ok { + t.Errorf("write %q is missing from the summary", w) + continue + } + if it.What == writeKindHelp[writeScannerHint].What { + t.Errorf("write %q is explained as the scanner hint: %+v", w, it) + } + if it.What != unexplainedWriteHelp.What { + t.Errorf("write %q is not reported as unexplained: %+v", w, it) + } + } + assertPlainItem(t, unexplainedWriteHelp) +} From 92c4fe2cb1e56f57c4bc2de534144db5008d1ace Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:59:20 +0100 Subject: [PATCH 060/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057112822?= =?UTF-8?q?=20=E2=80=94=20unkinded=20writes=20are=20unexplained?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057112822 Assisted-by: Claude:claude-opus-5-5 --- ...sult-explain-labels-a-write-that-carries-no-kind-as.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md (60%) diff --git a/.abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md b/.abcd/work/issues/resolved/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md similarity index 60% rename from .abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md rename to .abcd/work/issues/resolved/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md index 04d4b45b5..9afa14f54 100644 --- a/.abcd/work/issues/open/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md +++ b/.abcd/work/issues/resolved/iss-2609260057112822-installresult-explain-labels-a-write-that-carries-no-kind-as.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/install_summary.go" +resolution: "explain() reports a write with no kind, or an unknown kind, under an honest unexplained-write item that lists its path, instead of the scanner hint or nothing." +impact: internal +resolved_by: + commit: "47af5ce1" --- InstallResult.explain labels a write that carries no kind as the optional-scanner hint (its fallback is writeScannerHint), so an unkinded write would be explained as something it is not, and a kind with no entry in allWriteKinds drops out of the summary entirely. The fallback should be an honest unexplained-write item. + +## Grounds + +- pursued: the fallback item is never another kind's text; TestUnkindedWriteIsExplainedAsUnexplained would fail if an unkinded write were labelled as a known kind or dropped from the summary From 8f1441de34db200bb347b5ae2dc6f285bb534bab Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:00:03 +0100 Subject: [PATCH 061/107] fix(update): the foreign refusal's negatives describe the entry For a PATH entry that is a symlink, the refusal read "leads to Y, which is not ... a regular file abcd can verify", but the classifier examined the entry, not Y, and Y is typically a regular file. The entry is now the subject of every negative ("the entry at X, which resolves to Y, is not ... itself a regular file"), and where it leads is a fact about it. Refs: iss-2609260057117838 Assisted-by: Claude:claude-opus-5-5 --- internal/core/update/update.go | 9 ++++++--- internal/core/update/update_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/core/update/update.go b/internal/core/update/update.go index 30e4a599f..634f3a550 100644 --- a/internal/core/update/update.go +++ b/internal/core/update/update.go @@ -205,12 +205,15 @@ func Plan(t ahoy.UpdateTarget) *Refusal { // Say what was examined (iss-2608230943260391): "not owned" alone cannot // tell a reader whether the objection is the link, where it leads, or // the missing provenance record, and a reader who has just seen `abcd - // version` print "dev" expects the dev-shim shape instead. + // version` print "dev" expects the dev-shim shape instead. Every + // negative is about the ENTRY, the one thing the classifier examined: + // where a link leads is named as a fact about the entry, because the + // target itself may well be a regular file (iss-2609260057117838). detail := "the entry at " + targetPath if resolvedPath != "" && resolvedPath != targetPath { - detail += " leads to " + resolvedPath + ", which" + detail += ", which resolves to " + resolvedPath + "," } - detail += " is not something abcd owns: it is not abcd's dev shim, not a link into a plugin install, not a regular file abcd can verify, and no provenance record abcd wrote names it; abcd never clobbers a binary it does not own." + + detail += " is not something abcd owns: it is not abcd's dev shim, not a link into a plugin install, not itself a regular file abcd can verify, and no provenance record abcd wrote names it; abcd never clobbers a binary it does not own." + " A version of \"dev\" from `abcd version` is a build label (any locally built binary carries it), not the dev-shim install shape" if t.LaterOwned != "" { detail += "; a working abcd install sits shadowed behind it at " + fsutil.RedactHome(t.LaterOwned) diff --git a/internal/core/update/update_test.go b/internal/core/update/update_test.go index a3aa7d2a4..771ace272 100644 --- a/internal/core/update/update_test.go +++ b/internal/core/update/update_test.go @@ -639,3 +639,30 @@ func TestPlanForeignRefusalNamesWhatItExamined(t *testing.T) { } } } + +// TestPlanForeignRefusalJudgesTheLinkNotItsTarget is iss-2609260057117838: +// for a PATH entry that is a symlink, the refusal said the link's target "is +// not ... a regular file abcd can verify", while the target IS a regular file +// (the classifier Lstat'd the entry, not what it leads to). The negatives +// belong to the entry, which is what was examined; where it leads is named as +// a fact about the entry, never as the subject of the negatives. +func TestPlanForeignRefusalJudgesTheLinkNotItsTarget(t *testing.T) { + r := Plan(ahoy.UpdateTarget{Path: "/x/abcd", ResolvedPath: "/src/bin/abcd-darwin-arm64", Kind: ahoy.UpdateTargetForeign}) + if r == nil { + t.Fatal("foreign target must refuse") + } + if strings.Contains(r.Detail, "which is not") { + t.Errorf("the refusal attaches its negatives to the link's target, which the classifier never examined: %q", r.Detail) + } + for _, want := range []string{"the entry at /x/abcd, which resolves to /src/bin/abcd-darwin-arm64, is not", "not itself a regular file"} { + if !strings.Contains(r.Detail, want) { + t.Errorf("the refusal does not say %q: %q", want, r.Detail) + } + } + + // An entry that resolves nowhere else carries the same negatives, each true of it. + plain := Plan(ahoy.UpdateTarget{Path: "/x/abcd", Kind: ahoy.UpdateTargetForeign}) + if plain == nil || !strings.Contains(plain.Detail, "the entry at /x/abcd is not") || !strings.Contains(plain.Detail, "not itself a regular file") { + t.Errorf("a foreign entry that resolves nowhere else lost its negatives: %+v", plain) + } +} From 512d695dc536cf28dc2c1f4fa98f133adb21310a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:00:07 +0100 Subject: [PATCH 062/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057117838?= =?UTF-8?q?=20=E2=80=94=20foreign=20refusal=20judges=20the=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057117838 Assisted-by: Claude:claude-opus-5-5 --- ...update-s-foreign-refusal-for-a-path-entry-that-is-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md (58%) diff --git a/.abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md b/.abcd/work/issues/resolved/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md similarity index 58% rename from .abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md rename to .abcd/work/issues/resolved/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md index 9b11cd258..c288760a2 100644 --- a/.abcd/work/issues/open/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md +++ b/.abcd/work/issues/resolved/iss-2609260057117838-abcd-update-s-foreign-refusal-for-a-path-entry-that-is-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/update/update.go" +resolution: "The foreign refusal attaches every negative to the PATH entry the classifier examined; the resolved path is named as where the entry resolves, not as the subject of 'is not a regular file'." +impact: fix +resolved_by: + commit: "8f1441de" --- abcd update's foreign refusal, for a PATH entry that is a symlink, reads: the entry at X leads to Y, which is not ... a regular file abcd can verify. But Y is a regular file (the classifier Lstat'd the entry, not Y), so the person is told something false about the link's target. The negatives belong to the entry. + +## Grounds + +- pursued: the sentence is true of a link whose target is a regular file; TestPlanForeignRefusalJudgesTheLinkNotItsTarget would fail if the negatives were attached to the resolved path again From 2d5e3f261db4b011ba671a7206bd4b7eb746946e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:00:50 +0100 Subject: [PATCH 063/107] fix(ahoy): missing plugin files are worded once, plainly One abcd ahoy render named the state twice: the plugin.root_missing gap in plain words and the guard-health line as "plugin root not resolvable". Both now say the one sentence, held in a single constant, and the tests that pinned the old wording hold the new one instead. Refs: iss-2609260057111298 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/detect.go | 8 +++++++- internal/core/ahoy/guard_health.go | 2 +- internal/core/ahoy/guard_health_test.go | 17 ++++++++++++++++- internal/surface/cli/guard_health_line_test.go | 6 +++--- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 86b84872e..316519a78 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -173,6 +173,12 @@ func gitPresent(cwd string) bool { return err == nil } +// pluginFilesMissing is the one sentence for the state in which abcd cannot find +// the folder its plugin was installed into. The plugin.root_missing gap and the +// guard-health reason both say it, because one render shows both, and a state +// worded two ways reads as two problems (iss-2609260057111298). +const pluginFilesMissing = "abcd looked for the folder its plugin was installed into and found none, so it cannot check the automatic hooks that run it" + func detectPluginRoot(ok bool) []Gap { if ok { return nil @@ -184,7 +190,7 @@ func detectPluginRoot(ok bool) []Gap { // Plain words for the person, not the mechanism (iss-164): the two // environment names are named only in the fix hint, with what they are. Title: "abcd's plugin files were not found on this machine", - Detail: "abcd looked for the folder its plugin was installed into and found none, so it cannot check the automatic hooks that run it.", + Detail: pluginFilesMissing + ".", FixHint: "Reinstall the abcd plugin in your AI assistant; or, to point abcd at a plugin folder by hand, set the ABCD_PLUGIN_ROOT environment variable to that folder (the assistant normally supplies it as CLAUDE_PLUGIN_ROOT).", }} } diff --git a/internal/core/ahoy/guard_health.go b/internal/core/ahoy/guard_health.go index db975b3c0..40e3594bd 100644 --- a/internal/core/ahoy/guard_health.go +++ b/internal/core/ahoy/guard_health.go @@ -78,7 +78,7 @@ func detectGuardHealth(cwd, pluginRoot string, pluginOK bool) GuardHealth { var reasons []string h.PluginRootResolved = pluginOK if !pluginOK { - reasons = append(reasons, "plugin root not resolvable, so the hook manifest cannot be read and the guard wiring is unknown") + reasons = append(reasons, pluginFilesMissing) } else { h.HookInstalled = manifestArmsGuard(pluginRoot) h.BinaryReachable = isExecutableFile(pluginBinaryPath(pluginRoot)) diff --git a/internal/core/ahoy/guard_health_test.go b/internal/core/ahoy/guard_health_test.go index 935114932..9cbafe891 100644 --- a/internal/core/ahoy/guard_health_test.go +++ b/internal/core/ahoy/guard_health_test.go @@ -259,7 +259,7 @@ func TestGuardHealthUnresolvablePluginRootAssertsNothing(t *testing.T) { if hasGap(det.Gaps, "guard.hook_missing") || hasGap(det.Gaps, "guard.binary_unreachable") { t.Errorf("ahoy must not accuse a manifest it never opened; gaps = %v", gapIDs(det.Gaps)) } - if !strings.Contains(det.Guard.Detail, "plugin root") { + if !strings.Contains(det.Guard.Detail, "cannot check the automatic hooks") { t.Errorf("the reason must name what is actually unknown; detail = %q", det.Guard.Detail) } // The registry is a repo fact and stays answerable regardless of the plugin. @@ -294,3 +294,18 @@ func TestGuardHealthDisabledIsReported(t *testing.T) { t.Error("a committed kill switch must be reported by ahoy, not hidden") } } + +// TestGuardHealthNamesMissingPluginFilesAsTheGapDoes is iss-2609260057111298: +// one abcd ahoy render showed the missing-plugin-files state twice, once as the +// plain plugin.root_missing gap and once as the guard line's "plugin root not +// resolvable". Both now say the one plain sentence. +func TestGuardHealthNamesMissingPluginFilesAsTheGapDoes(t *testing.T) { + h := detectGuardHealth(t.TempDir(), "", false) + gap := detectPluginRoot(false)[0] + if !strings.Contains(h.Detail, strings.TrimSuffix(gap.Detail, ".")) { + t.Errorf("the guard line and the gap word one state two ways:\n guard: %q\n gap: %q", h.Detail, gap.Detail) + } + if strings.Contains(strings.ToLower(h.Detail), "plugin root") { + t.Errorf("the guard line uses insider vocabulary: %q", h.Detail) + } +} diff --git a/internal/surface/cli/guard_health_line_test.go b/internal/surface/cli/guard_health_line_test.go index 6059d7fc1..0bd05b563 100644 --- a/internal/surface/cli/guard_health_line_test.go +++ b/internal/surface/cli/guard_health_line_test.go @@ -17,15 +17,15 @@ func TestGuardHealthLineNeverAssertsWhatItCannotKnow(t *testing.T) { PluginRootResolved: false, RegistryLoadable: true, Entries: 6, - Detail: "plugin root not resolvable, so the hook manifest cannot be read", + Detail: "abcd looked for the folder its plugin was installed into and found none, so it cannot check the automatic hooks that run it", } line := guardHealthLine(h) if strings.Contains(line, "hook not installed") || strings.Contains(line, "binary unreachable") { t.Errorf("the line asserts facts that were never checked: %q", line) } - if !strings.Contains(line, "plugin root") { - t.Errorf("the line must carry the reason the state is unknown; got %q", line) + if !strings.Contains(line, "UNKNOWN") || !strings.Contains(line, h.Detail) { + t.Errorf("the line must say the state is unknown and carry the reason; got %q", line) } } From 191787b177d6d4a222e6ea004a4bd4b8e3711fa5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:00:53 +0100 Subject: [PATCH 064/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057111298?= =?UTF-8?q?=20=E2=80=94=20one=20wording=20for=20missing=20plugin=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057111298 Assisted-by: Claude:claude-opus-5-5 --- ...lugin-files-missing-state-is-worded-two-ways-in-one.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md (58%) diff --git a/.abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md b/.abcd/work/issues/resolved/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md similarity index 58% rename from .abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md rename to .abcd/work/issues/resolved/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md index 6b300decb..de677f9a5 100644 --- a/.abcd/work/issues/open/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md +++ b/.abcd/work/issues/resolved/iss-2609260057111298-the-plugin-files-missing-state-is-worded-two-ways-in-one.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/guard_health.go" +resolution: "The plugin.root_missing gap and the guard-health reason share one plain sentence (pluginFilesMissing); the guard line no longer says 'plugin root not resolvable'." +impact: fix +resolved_by: + commit: "2d5e3f26" --- The plugin-files-missing state is worded two ways in one abcd ahoy render: the plugin.root_missing gap speaks plainly, but the guard-health reason still says: plugin root not resolvable, so the hook manifest cannot be read and the guard wiring is unknown. + +## Grounds + +- pursued: one state reads as one problem in abcd ahoy; TestGuardHealthNamesMissingPluginFilesAsTheGapDoes would fail if either wording drifted from the other or the insider phrase returned From 079daddf4bbf757a60c266f8a17d621b19222b5f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:01:25 +0100 Subject: [PATCH 065/107] fix(gitutil): the checkout-root refusal names the core.worktree cause Toplevel refuses a toplevel that does not contain the working directory, which is what git answers when core.worktree names a tree elsewhere, and CheckoutRoot then refuses. Its message listed three causes, none of them this one, so a person in such a checkout was pointed at the wrong fix. The message names the fourth cause; the front-door phrase is unchanged. Refs: iss-2609260057112155 Assisted-by: Claude:claude-opus-5-5 --- internal/gitutil/repo.go | 6 ++++-- internal/gitutil/toplevel_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/gitutil/repo.go b/internal/gitutil/repo.go index 6467bcc6b..923b55a8f 100644 --- a/internal/gitutil/repo.go +++ b/internal/gitutil/repo.go @@ -296,7 +296,9 @@ var ErrNoCheckoutRoot = errors.New("no checkout root") // // - git names a toplevel: that is the answer, whoever owns the checkout. // - git will not answer for a repo-SHAPED tree (git absent from PATH, a -// corrupt .git, an ownership refusal under the isolated env): REFUSED, +// corrupt .git, an ownership refusal under the isolated env, or an answer +// Toplevel refuses for its shape, which a core.worktree setting naming a +// tree that does not contain cwd produces): REFUSED, // naming that git could not answer. RepoShapedRoot is read here as a // CLASSIFIER and never as a root: it is a marker walk, which accepts any // directory merely carrying the name and has neither the shape check nor @@ -317,7 +319,7 @@ func CheckoutRoot(cwd, store string) (string, error) { // leaks an absolute local path (iss-76), and the caller already knows where // they are standing. if RepoShapedRoot(cwd) != "" { - return "", fmt.Errorf("%w: git could not name the repository root for the working directory (git absent from PATH, the repository unreadable, or its ownership refused), and %s is never guessed at", + return "", fmt.Errorf("%w: git could not name the repository root for the working directory (git absent from PATH, the repository unreadable, its ownership refused, or a core.worktree setting naming a working tree that does not contain this directory), and %s is never guessed at", ErrNoCheckoutRoot, store) } return "", fmt.Errorf("%w: the working directory is not inside a git repository, and %s is per-repository: run this from a checkout", diff --git a/internal/gitutil/toplevel_test.go b/internal/gitutil/toplevel_test.go index f08d6121a..0873b64cc 100644 --- a/internal/gitutil/toplevel_test.go +++ b/internal/gitutil/toplevel_test.go @@ -112,3 +112,34 @@ func TestShowToplevelIsAskedOnlyThroughToplevel(t *testing.T) { t.Fatal(err) } } + +// TestCheckoutRootRefusalNamesAWorktreeSettingPointingElsewhere is +// iss-2609260057112155: with core.worktree naming a working tree that does not +// contain the caller's directory, git answers with a toplevel Toplevel refuses +// for its shape, and CheckoutRoot refuses in turn. The refusal listed three +// causes (git absent, the repository unreadable, its ownership refused), none of +// which is this one, so it must name the fourth. +func TestCheckoutRootRefusalNamesAWorktreeSettingPointingElsewhere(t *testing.T) { + repo := t.TempDir() + if out, err := runGit(t, repo, "init", "-q"); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + elsewhere := t.TempDir() + if out, err := runGit(t, repo, "config", "core.worktree", elsewhere); err != nil { + t.Fatalf("git config: %v: %s", err, out) + } + sub := filepath.Join(repo, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + _, err := gitutil.CheckoutRoot(sub, "the issue ledger") + if err == nil { + t.Fatal("a checkout whose core.worktree points elsewhere was given a root") + } + if !strings.Contains(err.Error(), "core.worktree") { + t.Errorf("the refusal does not name the core.worktree cause: %v", err) + } + if !strings.Contains(err.Error(), "git could not name the repository root") { + t.Errorf("the refusal lost the phrase its front-door tests match: %v", err) + } +} From 0f429bd0092bb142e5f52078f3303aa0067e2b3a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:01:28 +0100 Subject: [PATCH 066/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057112155?= =?UTF-8?q?=20=E2=80=94=20refusal=20names=20core.worktree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057112155 Assisted-by: Claude:claude-opus-5-5 --- ...l-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md (64%) diff --git a/.abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md b/.abcd/work/issues/resolved/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md similarity index 64% rename from .abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md rename to .abcd/work/issues/resolved/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md index 1f72d3726..6dacb5293 100644 --- a/.abcd/work/issues/open/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md +++ b/.abcd/work/issues/resolved/iss-2609260057112155-gitutil-checkoutroot-s-refusal-for-a-repo-shaped-tree-git.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/gitutil/repo.go" +resolution: "CheckoutRoot's refusal names a core.worktree setting naming a working tree that does not contain the directory as the fourth cause." +impact: fix +resolved_by: + commit: "079daddf" --- gitutil.CheckoutRoot's refusal for a repo-shaped tree git will not answer for names three causes (git absent from PATH, the repository unreadable, its ownership refused) and omits the fourth that Toplevel's shape check refuses: a core.worktree setting whose working tree does not contain the working directory. A person in that checkout is offered three causes, none of which is theirs. + +## Grounds + +- pursued: every cause Toplevel can refuse on is named; TestCheckoutRootRefusalNamesAWorktreeSettingPointingElsewhere drives real git with core.worktree set and would fail if the cause were dropped From 1aabbfbec3297c31b0da8f4bc36921e491b951b3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:02:47 +0100 Subject: [PATCH 067/107] fix(ahoy): a data dir its group can write, or another owns, is refused dataDirHazard refused only a world-writable data directory or cache, so an attested cache in a group-writable directory was still promoted onto PATH as the owned binary, and one another account owns was never asked about. The caller-alone test ReadDeclaration applies (no group or other write bit, owned by this uid) is extracted as fsutil.CallersAlone, and both ReadDeclaration and dataDirHazard call it, so there is one copy of it. On a host whose umask leaves directories group-writable the refusal costs the owned copy: the install degrades to the pinned symlink and its note says why, which the comment on dataDirHazard now states. Refs: iss-2609260057111315 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/data_dir.go | 39 +++++++----- internal/core/ahoy/data_dir_owner_test.go | 77 +++++++++++++++++++++++ internal/fsutil/fsutil.go | 39 +++++++++--- 3 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 internal/core/ahoy/data_dir_owner_test.go diff --git a/internal/core/ahoy/data_dir.go b/internal/core/ahoy/data_dir.go index aff1ac192..4617b73ac 100644 --- a/internal/core/ahoy/data_dir.go +++ b/internal/core/ahoy/data_dir.go @@ -1,6 +1,7 @@ package ahoy import ( + "errors" "os" "path/filepath" "strings" @@ -113,18 +114,21 @@ func insideRepo(cwd, p string) bool { // dataDirHazard reports why dataDir cannot be trusted as the harness's // persistent data directory, or "" when it has the shape that directory always -// has: an absolute path, outside the repository being installed, not -// world-writable. Neither source pluginDataDir consults examines the value it -// hands back — CLAUDE_PLUGIN_DATA is read from the environment as given, and -// the plugin root's .data-dir stamp is checked only for being an existing -// absolute directory — and the owned-copy promotion re-verifies the cache only -// against the record beside it, so a value of any other shape would let -// whoever chose it — a -// relative value resolves against the checkout the verb runs in, an -// in-checkout value is committed bytes, a world-writable cache is any local -// user's — bless their own bytes as the owned PATH binary (sub-finding of -// GHSA-4q78-ccfv-f374). The harness never produces these shapes, so refusing -// them costs a real install nothing. This is the shape check only; the trust +// has: an absolute path, outside the repository being installed, and, with its +// cache/ subdirectory, owned by this uid and writable by nobody else (the +// fsutil.CallersAlone test the home-scoped declarations use). Neither source +// pluginDataDir consults examines the value it hands back — CLAUDE_PLUGIN_DATA +// is read from the environment as given, and the plugin root's .data-dir stamp +// is checked only for being an existing absolute directory — and the owned-copy +// promotion re-verifies the cache only against the record beside it, so a +// value of any other shape would let whoever chose it bless their own bytes as +// the owned PATH binary (sub-finding of GHSA-4q78-ccfv-f374): a relative value +// resolves against the checkout the verb runs in, an in-checkout value is +// committed bytes, and a cache its group or every user can write, or another +// account owns, is theirs (iss-2609260057111315). The harness produces none of +// these shapes under an ordinary umask; on a host whose umask leaves directories +// group-writable the refusal costs the owned copy, and the install degrades to +// the pinned symlink and says why. This is the shape check only; the trust // binding — the cache is promoted only when ~/.abcd/cache-attestation names // the directory and its recorded hash — is cacheBindingProblem, and a // directory that passes here is still not promoted without it. @@ -139,8 +143,15 @@ func dataDirHazard(dataDir, cwd string) string { return "it lies inside the repository being installed, so its cache would be committed bytes" } for _, dir := range []string{dataDir, filepath.Join(dataDir, "cache")} { - if fi, err := os.Stat(dir); err == nil && fi.IsDir() && fi.Mode().Perm()&0o002 != 0 { - return "it is world-writable (" + displayPath(dir) + "), so any local user could replace both the artefact and its recorded hash" + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + continue + } + switch err := fsutil.CallersAlone(dir, fi); { + case errors.Is(err, fsutil.ErrDeclarationWritable): + return "it is writable by its group or by every user (" + displayPath(dir) + "), so someone other than you could replace both the artefact and its recorded hash" + case err != nil: + return "it is owned by another account (" + displayPath(dir) + "), or its owner could not be read, so that account could replace both the artefact and its recorded hash" } } return "" diff --git a/internal/core/ahoy/data_dir_owner_test.go b/internal/core/ahoy/data_dir_owner_test.go new file mode 100644 index 000000000..3bb1006a8 --- /dev/null +++ b/internal/core/ahoy/data_dir_owner_test.go @@ -0,0 +1,77 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// TestDataDirHazardRefusesAnyoneElsesDirectory is iss-2609260057111315: the +// shape check refused a world-writable data directory, but a group-writable one +// or one another account owns lets that group or account supply both the cache +// artefact and its recorded hash just the same. Each is refused, for the data +// directory and for its cache/ subdirectory, and a directory the caller alone +// can write still passes. +func TestDataDirHazardRefusesAnyoneElsesDirectory(t *testing.T) { + repo := t.TempDir() + fresh := func(t *testing.T) string { + t.Helper() + data := t.TempDir() + if err := os.Mkdir(filepath.Join(data, "cache"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(data, 0o755); err != nil { + t.Fatal(err) + } + return data + } + + if h := dataDirHazard(fresh(t), repo); h != "" { + t.Fatalf("precondition: a directory only the caller can write is refused: %s", h) + } + + for _, sub := range []string{"", "cache"} { + data := fresh(t) + if err := os.Chmod(filepath.Join(data, sub), 0o775); err != nil { + t.Fatal(err) + } + h := dataDirHazard(data, repo) + if h == "" { + t.Errorf("a group-writable %q under the data directory is not refused", "./"+sub) + } else if !strings.Contains(h, "group") { + t.Errorf("the group-writable refusal does not say so: %s", h) + } + } + + data := fresh(t) + t.Cleanup(fsutil.SwapOwnerUIDForTest(func(string) (uint32, error) { + return uint32(os.Getuid()) + 1, nil + })) + if h := dataDirHazard(data, repo); h == "" { + t.Error("a data directory another account owns is not refused") + } else if !strings.Contains(h, "another account") { + t.Errorf("the foreign-owner refusal does not say so: %s", h) + } +} + +// TestInstallIgnoresGroupWritableDataCache is the install-level twin of the +// world-writable case: an attested cache in a group-writable directory is still +// not promoted, and the note names the data directory. +func TestInstallIgnoresGroupWritableDataCache(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + data := seedDataCache(t, cacheArtefact) + if err := os.Chmod(filepath.Join(data, "cache"), 0o775); err != nil { + t.Fatal(err) + } + + res, err := Install(adoptableRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + assertCacheIgnored(t, filepath.Join(binDir, "abcd"), res) +} diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 9ed8fd620..53c86692f 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -194,14 +194,11 @@ func ReadDeclaration(path string, limit int64) ([]byte, DeclarationRefusal, erro if !fi.Mode().IsRegular() { return nil, DeclarationNotRegular, ErrNotRegular } - if fi.Mode().Perm()&0o022 != 0 { - return nil, DeclarationWritableByOthers, ErrDeclarationWritable - } - // An unreadable owner is refused too: "I could not learn who owns this" and - // "I own this" are different answers, and a fail-closed gate must not spell - // them the same way. - if owner, err := ownerUID(path); err != nil || owner != uint32(os.Getuid()) { - return nil, DeclarationForeignOwner, ErrDeclarationForeignOwner + if err := CallersAlone(path, fi); err != nil { + if errors.Is(err, ErrDeclarationWritable) { + return nil, DeclarationWritableByOthers, err + } + return nil, DeclarationForeignOwner, err } declarationVetted(path) raw, err := readGuarded(path, limit, fi) @@ -211,6 +208,32 @@ func ReadDeclaration(path string, limit int64) ([]byte, DeclarationRefusal, erro return raw, DeclarationOK, nil } +// CallersAlone is the half of ReadDeclaration's judgement that makes a path the +// caller's word, for a path the caller has already stat'd (fi describes it): nil +// when fi carries no group or other write bit AND path is owned by this +// session's uid. Otherwise ErrDeclarationWritable (someone else could write it) +// or ErrDeclarationForeignOwner (another uid owns it, or its owner could not be +// read). It is exported so a check on something other than a declaration file — +// a directory whose contents are trusted, such as the harness data directory +// ahoy promotes a binary out of — applies this one test rather than a copy of +// it (iss-2609260057111315). +// +// Ownership is looked up through path (OwnerUID follows symlinks), so a caller +// that must judge a link as itself Lstat's and refuses it before calling, as +// ReadDeclaration does. +func CallersAlone(path string, fi os.FileInfo) error { + if fi.Mode().Perm()&0o022 != 0 { + return ErrDeclarationWritable + } + // An unreadable owner is refused too: "I could not learn who owns this" and + // "I own this" are different answers, and a fail-closed gate must not spell + // them the same way. + if owner, err := ownerUID(path); err != nil || owner != uint32(os.Getuid()) { + return ErrDeclarationForeignOwner + } + return nil +} + // ReadGuardedInRoot is ReadGuarded resolved inside an os.Root containment // scope. rel is a slash-separated path relative to root; every component is // resolved by the OS within root, so a symlinked ANCESTOR directory — the shape From 48327d27d59943ec123d67cf6c72defda9d32545 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:02:50 +0100 Subject: [PATCH 068/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057111315?= =?UTF-8?q?=20=E2=80=94=20data=20dir=20must=20be=20the=20caller's=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057111315 Assisted-by: Claude:claude-opus-5-5 --- ...zard-refuses-a-world-writable-plugin-data-directory.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md (55%) diff --git a/.abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md b/.abcd/work/issues/resolved/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md similarity index 55% rename from .abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md rename to .abcd/work/issues/resolved/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md index 040ba5779..51edc61c6 100644 --- a/.abcd/work/issues/open/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md +++ b/.abcd/work/issues/resolved/iss-2609260057111315-datadirhazard-refuses-a-world-writable-plugin-data-directory.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/data_dir.go" +resolution: "dataDirHazard applies fsutil.CallersAlone (extracted from ReadDeclaration, which now calls it too) to the data directory and its cache/, refusing group-writable and foreign-owned directories as well as world-writable ones." +impact: fix +resolved_by: + commit: "1aabbfbe" --- dataDirHazard refuses a world-writable plugin data directory or cache subdirectory, but not a group-writable one or one owned by another uid, so on a shared host a group member or another account can still supply the cache artefact and its recorded hash the hazard check claims to rule out. It should apply the caller-alone test the home-scoped declarations use (not writable by group or other, owned by this uid). + +## Grounds + +- pursued: only a directory the caller alone can write supplies the owned PATH copy; TestDataDirHazardRefusesAnyoneElsesDirectory and TestInstallIgnoresGroupWritableDataCache would fail if a 0775 or foreign-owned data dir were accepted, and the declaration tests would fail if the extraction changed ReadDeclaration From bb61dd123d11c2d50a54439c2ad931d0978801cc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:03:36 +0100 Subject: [PATCH 069/107] test(ahoy): the exported-reach audit parses callers instead of matching text The caller audit matched `ahoy.X` with a regex over raw source, so a comment or string literal naming a function, or a .go file under testdata/, counted as a caller and could hide dead scaffolding. It now parses each file and counts only a selector on the imported ahoy package, under whatever name it is imported, and skips testdata/. The open record for generalising the audit notes the matcher and the one looseness left, the name-only ForTest exemption. Refs: iss-2609252211487887 Assisted-by: Claude:claude-opus-5-5 --- ...ller-audit-iss-33-asked-for-exists-only.md | 2 + internal/core/ahoy/exported_reach_test.go | 100 ++++++++++++++++-- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md b/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md index bce532733..8fa336dc8 100644 --- a/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md +++ b/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md @@ -12,3 +12,5 @@ found_at: "internal/core/ahoy/exported_reach_test.go" --- The exported-reach caller audit iss-33 asked for exists only for internal/core/ahoy (TestEveryExportedAhoyFunctionHasAFrontDoor). A crude survey of the other internal/core packages (an exported top-level function with no 'pkg.Name' selector in non-test Go outside its package) lists about 140 names; many are reached only inside their own package, which is over-export rather than dead code, but some have no production caller anywhere, e.g. launch.Ship (grep for '.Ship(' outside tests finds none). Each hit needs sorting into dead scaffolding (delete or wire), in-package-only (unexport), or a declared test seam (name it ...ForTest), and the audit then generalised to every core package. + +Review 1 of the ahoy lane found the audit's caller match was a regex over raw source, so a comment or a string literal naming ahoy.X, or a .go file under testdata/, counted as a caller. The ahoy audit now parses each file (go/parser) and counts only a selector on the imported ahoy package, with testdata/ excluded; the generalisation this record asks for should reuse that matcher rather than the regex. One looseness remains by design: the ForTest suffix exempts a function as a declared test seam by its name alone, with nothing checking that production never calls it. diff --git a/internal/core/ahoy/exported_reach_test.go b/internal/core/ahoy/exported_reach_test.go index 4bbd1aa3d..163993b52 100644 --- a/internal/core/ahoy/exported_reach_test.go +++ b/internal/core/ahoy/exported_reach_test.go @@ -1,13 +1,13 @@ package ahoy import ( + "fmt" "go/ast" "go/parser" "go/token" "io/fs" "os" "path/filepath" - "regexp" "sort" "strings" "testing" @@ -21,9 +21,12 @@ import ( // calls it (loud-staging.md); an exported one nothing reaches is refused here. // // It reads the package's own non-test files for exported top-level functions, -// then the module's non-test Go files outside the package for a selector -// naming each (ahoy.Name). Tests do not count as callers: a function reached -// only by its own test is exactly the scaffolding the rule is about. +// then parses the module's non-test Go files outside the package (testdata/ +// excluded) for a selector on the imported ahoy package naming each. Tests do +// not count as callers: a function reached only by its own test is exactly the +// scaffolding the rule is about. Nor do comments or string literals, which a +// text match would have counted. The ForTest suffix exempts a declared +// cross-package test seam by its name alone. func TestEveryExportedAhoyFunctionHasAFrontDoor(t *testing.T) { fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool { @@ -62,14 +65,15 @@ func TestEveryExportedAhoyFunctionHasAFrontDoor(t *testing.T) { t.Fatalf("module root not found at %s: %v", root, err) } self, _ := filepath.Abs(".") - selector := regexp.MustCompile(`\bahoy\.([A-Z][A-Za-z0-9_]*)`) err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { name := d.Name() - if p == self || name == ".git" || name == "node_modules" || (strings.HasPrefix(name, ".") && p != root) { + // testdata/ holds fixtures the go tool never builds, so a .go file + // there is not production code and names no caller. + if p == self || name == ".git" || name == "node_modules" || name == "testdata" || (strings.HasPrefix(name, ".") && p != root) { return filepath.SkipDir } return nil @@ -81,9 +85,13 @@ func TestEveryExportedAhoyFunctionHasAFrontDoor(t *testing.T) { if err != nil { return err } - for _, m := range selector.FindAllStringSubmatch(string(data), -1) { - if _, ok := exported[m[1]]; ok { - exported[m[1]] = true + names, err := ahoySelectors(data) + if err != nil { + return fmt.Errorf("%s: %w", p, err) + } + for _, name := range names { + if _, ok := exported[name]; ok { + exported[name] = true } } return nil @@ -102,3 +110,77 @@ func TestEveryExportedAhoyFunctionHasAFrontDoor(t *testing.T) { t.Fatalf("exported ahoy functions no production code outside the package calls — wire a front door or unexport/delete them: %v", unreached) } } + +// ahoyImportPath is the package whose exported functions the audit holds to a +// front door. +const ahoyImportPath = "github.com/intentdriven/abcd/internal/core/ahoy" + +// ahoySelectors names every identifier src selects from the imported ahoy +// package, under whatever name the file imports it as. It parses the file +// rather than matching text, so a comment or a string literal naming ahoy.X is +// not a caller, and a file that does not import the package has none. A file +// that does not parse returns the error, so the audit fails loudly rather than +// counting nothing for it. +func ahoySelectors(src []byte) ([]string, error) { + f, err := parser.ParseFile(token.NewFileSet(), "", src, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + local := "" + for _, imp := range f.Imports { + if strings.Trim(imp.Path.Value, "`\"") != ahoyImportPath { + continue + } + local = "ahoy" + if imp.Name != nil { + local = imp.Name.Name + } + } + if local == "" || local == "_" || local == "." { + return nil, nil + } + var out []string + ast.Inspect(f, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + if id, ok := sel.X.(*ast.Ident); ok && id.Name == local { + out = append(out, sel.Sel.Name) + } + return true + }) + return out, nil +} + +// TestAhoySelectorsCountsOnlyCode is the audit's own detector: a comment or a +// string literal naming ahoy.X is not a caller, and neither is a selector on +// some other package that happens to be spelt ahoy; only a selector on the +// imported ahoy package, under whatever name it is imported, is. +func TestAhoySelectorsCountsOnlyCode(t *testing.T) { + src := []byte(`package p + +import ( + "fmt" + + abcdahoy "github.com/intentdriven/abcd/internal/core/ahoy" +) + +// ahoy.InComment is named only here. +func f() { + fmt.Println("ahoy.InString") + _ = abcdahoy.Called +} +`) + got, err := ahoySelectors(src) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "Called" { + t.Fatalf("ahoySelectors = %v; want only [Called]", got) + } + unimported := []byte("package p\n\nvar ahoy struct{ Shadow int }\n\nvar _ = ahoy.Shadow\n") + if got, _ := ahoySelectors(unimported); len(got) != 0 { + t.Fatalf("a selector on a local value named ahoy counted as a caller: %v", got) + } +} From 13e5a522b2f23f7938230e8f0fe250a05aa5ccc5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:06:21 +0100 Subject: [PATCH 070/107] fix(ahoy): a failed pin, .gitignore or marker write says what was not done Three install writes still dropped their failures, the class iss-227 fixed for their siblings. The identity pin said nothing when git's identity was unset or unreadable or when WritePin refused it; the visibility step dropped applyVisibilityBlock's refusal, so a symlinked .gitignore left no fence and no reason; and the marker step dropped a failed placement or retraction of abcd's block. Each now leaves a note naming the file and the reason. installMarkerFile and removeMarkerFile return the reason as an error instead of a bare ok flag, so the note and embark's EnsureMarker can both say why. Refs: iss-2609260057127611, iss-227 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/apply.go | 51 ++++++++---- internal/core/ahoy/embark_marker.go | 10 +-- internal/core/ahoy/marker.go | 84 ++++++++++---------- internal/core/ahoy/marker_test.go | 42 +++++----- internal/core/ahoy/marker_unclosed_test.go | 6 +- internal/core/ahoy/swallowed_writes_test.go | 86 +++++++++++++++++++++ 6 files changed, 197 insertions(+), 82 deletions(-) create mode 100644 internal/core/ahoy/swallowed_writes_test.go diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 5f5670127..a461028d3 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -410,13 +410,21 @@ func (a *applyCtx) stepIdentityPin() { if a.autoYes || !a.approved[ConfigChange] || !a.has(OptionalPinGapID) { return } + // Each way of recording nothing is said, not dropped (iss-2609260057127611). eff, err := identity.EffectiveIdentity(a.cwd) - if err != nil || eff.Name == "" || eff.Email == "" { + if err != nil { + a.refuse("did not record who commits to this repository in " + identity.PinRelPath + ": git's user.name and user.email could not be read: " + errText(err)) + return + } + if eff.Name == "" || eff.Email == "" { + a.refuse("did not record who commits to this repository in " + identity.PinRelPath + ": git has no user.name and user.email set here; set them and run abcd ahoy install again") return } - if err := identity.WritePin(a.cwd, identity.Pin{Name: eff.Name, Email: eff.Email}); err == nil { - a.note(writeIdentityPin, identity.PinRelPath) + if err := identity.WritePin(a.cwd, identity.Pin{Name: eff.Name, Email: eff.Email}); err != nil { + a.refuse("could not record who commits to this repository in " + identity.PinRelPath + ": " + errText(err)) + return } + a.note(writeIdentityPin, identity.PinRelPath) } func (a *applyCtx) has(id string) bool { return a.gapPresent[id] } @@ -698,17 +706,22 @@ func (a *applyCtx) stepVisibility(cfg *InstallConfig) { return } wrote, err := applyVisibilityBlock(a.cwd, cfg.Visibility) - if err == nil && wrote { + if err != nil { + // Said, not dropped (iss-2609260057127611): without the block git is not + // told which abcd files stay on this machine. + a.refuse("could not write abcd's block into .gitignore: " + errText(err) + "; git is not told which abcd files stay on this machine") + return + } + if wrote { a.note(writeGitignore, filepath.Join(a.cwd, ".gitignore")) } // A narrowed public fence is said out loud (iss-255): the reader must learn // that the committed record tiers stay published, from the receipt rather - // than from a later surprise in git status. Gated on the write succeeding — - // a refused .gitignore holds no fence, and the note must not assert one. - if err == nil { - if _, narrowed := effectiveVisibilityEntries(a.cwd, cfg.Visibility); narrowed { - a.refuse("visibility is public, but .abcd/ holds tracked files — an ignore rule cannot untrack committed records, so the .abcd/ fence covers only the local tier (.abcd/.work.local/; the memory/ snapshot fence is kept) and the committed record tiers remain published") - } + // than from a later surprise in git status. Reached only when the write + // succeeded — a refused .gitignore holds no fence, and the note must not + // assert one. + if _, narrowed := effectiveVisibilityEntries(a.cwd, cfg.Visibility); narrowed { + a.refuse("visibility is public, but .abcd/ holds tracked files — an ignore rule cannot untrack committed records, so the .abcd/ fence covers only the local tier (.abcd/.work.local/; the memory/ snapshot fence is kept) and the committed record tiers remain published") } } @@ -921,7 +934,12 @@ func (a *applyCtx) stepMarker(cfg *InstallConfig) { } for _, name := range markerTargets(target) { path := filepath.Join(a.cwd, name) - if wrote, ok := installMarkerFile(path); ok && wrote { + wrote, err := installMarkerFile(path) + if err != nil { + a.refuse("could not write abcd's block into " + name + ": " + errText(err) + "; the file is left as it was") + continue + } + if wrote { a.note(writeConventionsBlock, path) } } @@ -929,7 +947,12 @@ func (a *applyCtx) stepMarker(cfg *InstallConfig) { // so a target change (e.g. both -> claude_md, or -> skip) leaves no orphan. for _, name := range a.markerRetract { path := filepath.Join(a.cwd, name) - if wrote, ok := removeMarkerFile(path); ok && wrote { + wrote, err := removeMarkerFile(path) + if err != nil { + a.refuse("could not remove abcd's block from " + name + ", which you no longer chose: " + errText(err) + "; the file is left as it was") + continue + } + if wrote { a.note(writeConventionsBlockRemoved, path) } } @@ -1440,9 +1463,9 @@ func Uninstall(cwd, binDir string) (UninstallReceipt, error) { // Marker: clean both surfaces regardless of the current docs.target. for _, name := range []string{"CLAUDE.md", "AGENTS.md"} { path := filepath.Join(abs, name) - if wrote, ok := removeMarkerFile(path); ok && wrote { + if wrote, err := removeMarkerFile(path); err == nil && wrote { receipt.Marker.Removed = append(receipt.Marker.Removed, name) - } else if !ok { + } else if err != nil { receipt.Marker.Skipped = append(receipt.Marker.Skipped, name) } } diff --git a/internal/core/ahoy/embark_marker.go b/internal/core/ahoy/embark_marker.go index 44e55197f..f29c41b0e 100644 --- a/internal/core/ahoy/embark_marker.go +++ b/internal/core/ahoy/embark_marker.go @@ -16,8 +16,8 @@ import ( // // It wraps the existing unexported classify/install machinery: dryRun maps // classifyMarker(path) → current→(false,nil), missing/outdated→(true,nil), -// symlink or unplaceable→(false, err); a real run calls installMarkerFile(path) → ok==false→ -// (false, err), else (wrote, nil). +// symlink or unplaceable→(false, err); a real run calls installMarkerFile(path) → its +// error wrapped as (false, err), else (wrote, nil). func EnsureMarker(path string, dryRun bool) (changed bool, err error) { if dryRun { switch classifyMarker(path) { @@ -34,9 +34,9 @@ func EnsureMarker(path string, dryRun bool) (changed bool, err error) { return false, fmt.Errorf("cannot classify marker at %s", filepath.Base(path)) } } - wrote, ok := installMarkerFile(path) - if !ok { - return false, fmt.Errorf("cannot write marker to %s", filepath.Base(path)) + wrote, err := installMarkerFile(path) + if err != nil { + return false, fmt.Errorf("cannot write marker to %s: %w", filepath.Base(path), err) } return wrote, nil } diff --git a/internal/core/ahoy/marker.go b/internal/core/ahoy/marker.go index 7cf9f2039..d74102de5 100644 --- a/internal/core/ahoy/marker.go +++ b/internal/core/ahoy/marker.go @@ -3,6 +3,7 @@ package ahoy import ( "bytes" _ "embed" + "fmt" "os" "regexp" "strings" @@ -109,53 +110,54 @@ func classifyMarker(targetPath string) markerState { } // installMarkerFile plants, updates, or leaves-current the block in one target. -// It returns (wrote, ok): ok=false means a per-target failure that leaves the -// file untouched. Byte-stable: a current block is not rewritten. -func installMarkerFile(targetPath string) (wrote bool, ok bool) { +// It returns (wrote, err): a non-nil err is a per-target failure that leaves the +// file untouched, and says why, so the install can tell the person which file +// kept no block and for what reason (iss-2609260057127611). Byte-stable: a +// current block is not rewritten. +func installMarkerFile(targetPath string) (wrote bool, err error) { // Reject a symlinked leaf so a planted symlink cannot redirect the write. - if fi, err := os.Lstat(targetPath); err == nil && fi.Mode()&os.ModeSymlink != 0 { - return false, false + if fi, lerr := os.Lstat(targetPath); lerr == nil && fi.Mode()&os.ModeSymlink != 0 { + return false, &ahoyError{"it is a symlink, and abcd never writes through one"} } - existing, err := fsutil.ReadGuarded(targetPath, maxAhoyFileBytes) + existing, rerr := fsutil.ReadGuarded(targetPath, maxAhoyFileBytes) absent := false - if err != nil { - if os.IsNotExist(err) { - absent = true - } else { - return false, false + if rerr != nil { + if !os.IsNotExist(rerr) { + return false, fmt.Errorf("it could not be read: %w", rerr) } + absent = true } eol := detectEOL(existing) synth := synthesizeMarker(markerInner, eol) if absent { body := append(append([]byte{}, synth...), eol...) - if err := fsutil.WriteFileAtomicPreserveMode(targetPath, body); err != nil { - return false, false + if werr := fsutil.WriteFileAtomicPreserveMode(targetPath, body); werr != nil { + return false, fmt.Errorf("it could not be written: %w", werr) } - return true, true + return true, nil } matches := markerBlockRe.FindAllIndex(existing, -1) if len(matches) == 0 { if appendsInsideOpenSpan(existing) { - return false, false + return false, &ahoyError{"a fenced block or HTML comment in it is never closed, so the block would land inside it"} } body := composeMarkerInsertion(existing, synth, eol) - if err := fsutil.WriteFileAtomicPreserveMode(targetPath, body); err != nil { - return false, false + if werr := fsutil.WriteFileAtomicPreserveMode(targetPath, body); werr != nil { + return false, fmt.Errorf("it could not be written: %w", werr) } - return true, true + return true, nil } first := matches[0] if len(matches) == 1 && bytes.Equal(existing[first[0]:first[1]], synth) { - return false, true // current — no write, mtime preserved + return false, nil // current — no write, mtime preserved } body := composeMarkerReplacement(existing, matches, synth) - if err := fsutil.WriteFileAtomicPreserveMode(targetPath, body); err != nil { - return false, false + if werr := fsutil.WriteFileAtomicPreserveMode(targetPath, body); werr != nil { + return false, fmt.Errorf("it could not be written: %w", werr) } - return true, true + return true, nil } // composeMarkerInsertion inserts synth into a file with no block: after @@ -253,32 +255,36 @@ func composeMarkerReplacement(existing []byte, matches [][]int, synth []byte) [] } // removeMarkerFile strips every abcd block from one target, collapsing the EOLs -// install introduced so install->uninstall round-trips. Returns (wrote, ok). -// A symlinked leaf or non-regular file is skipped (ok=false). -func removeMarkerFile(targetPath string) (wrote bool, ok bool) { - fi, err := os.Lstat(targetPath) - if err != nil { - if os.IsNotExist(err) { - return false, true // absent — nothing to remove +// install introduced so install->uninstall round-trips. Returns (wrote, err): a +// symlinked leaf, a non-regular file, or a failed read or write leaves the file +// untouched and returns why. +func removeMarkerFile(targetPath string) (wrote bool, err error) { + fi, lerr := os.Lstat(targetPath) + if lerr != nil { + if os.IsNotExist(lerr) { + return false, nil // absent — nothing to remove } - return false, false + return false, fmt.Errorf("it could not be examined: %w", lerr) } - if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { - return false, false + if fi.Mode()&os.ModeSymlink != 0 { + return false, &ahoyError{"it is a symlink, and abcd never writes through one"} } - existing, err := fsutil.ReadGuarded(targetPath, maxAhoyFileBytes) - if err != nil { - return false, false + if !fi.Mode().IsRegular() { + return false, &ahoyError{"it is not a regular file"} + } + existing, rerr := fsutil.ReadGuarded(targetPath, maxAhoyFileBytes) + if rerr != nil { + return false, fmt.Errorf("it could not be read: %w", rerr) } matches := markerBlockRe.FindAllIndex(existing, -1) if len(matches) == 0 { - return false, true // no block — untouched + return false, nil // no block — untouched } body := composeMarkerRemoval(existing, matches) - if err := fsutil.WriteFileAtomicPreserveMode(targetPath, body); err != nil { - return false, false + if werr := fsutil.WriteFileAtomicPreserveMode(targetPath, body); werr != nil { + return false, fmt.Errorf("it could not be written: %w", werr) } - return true, true + return true, nil } // StripMarkerBlock returns content with every abcd marker block (a balanced diff --git a/internal/core/ahoy/marker_test.go b/internal/core/ahoy/marker_test.go index 2c06f6b72..f11d1bea6 100644 --- a/internal/core/ahoy/marker_test.go +++ b/internal/core/ahoy/marker_test.go @@ -11,9 +11,9 @@ import ( func TestMarkerInsertIntoAbsentFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "CLAUDE.md") - wrote, ok := installMarkerFile(path) - if !ok || !wrote { - t.Fatalf("install into absent file: wrote=%v ok=%v", wrote, ok) + wrote, err := installMarkerFile(path) + if err != nil || !wrote { + t.Fatalf("install into absent file: wrote=%v err=%v", wrote, err) } if classifyMarker(path) != markerCurrent { t.Errorf("state after install = %q, want current", classifyMarker(path)) @@ -27,9 +27,9 @@ func TestMarkerInsertAfterFrontmatterAndH1(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } - wrote, ok := installMarkerFile(path) - if !ok || !wrote { - t.Fatalf("install: wrote=%v ok=%v", wrote, ok) + wrote, err := installMarkerFile(path) + if err != nil || !wrote { + t.Fatalf("install: wrote=%v err=%v", wrote, err) } got, err := os.ReadFile(path) if err != nil { @@ -63,7 +63,7 @@ func TestMarkerInsertSkipsFencedH1(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); !ok { + if _, err := installMarkerFile(path); err != nil { t.Fatal("install failed") } got, _ := os.ReadFile(path) @@ -102,7 +102,7 @@ func TestMarkerInsertFollowsTheCommonMarkFenceRule(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); !ok { + if _, err := installMarkerFile(path); err != nil { t.Fatal("install failed") } got, _ := os.ReadFile(path) @@ -144,8 +144,8 @@ func TestClassifySymlinkedMarkerIsNotResolvableGap(t *testing.T) { } // install refuses to write through the symlink, so the two must agree: no // resolvable gap paired with a silent no-op. - if wrote, ok := installMarkerFile(link); wrote || ok { - t.Fatalf("installMarkerFile through symlink: wrote=%v ok=%v, want false/false", wrote, ok) + if wrote, err := installMarkerFile(link); wrote || err == nil { + t.Fatalf("installMarkerFile through symlink: wrote=%v err=%v, want false and a refusal", wrote, err) } // detectMarkerDrift must not emit an actionable (required+resolvable) gap. for _, g := range detectMarkerDrift(dir) { @@ -161,16 +161,16 @@ func TestMarkerInstallIsIdempotent(t *testing.T) { if err := os.WriteFile(path, []byte("# Title\n\nprose\n"), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); !ok { + if _, err := installMarkerFile(path); err != nil { t.Fatal("first install failed") } first, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - wrote, ok := installMarkerFile(path) - if !ok { - t.Fatal("second install failed") + wrote, err := installMarkerFile(path) + if err != nil { + t.Fatalf("second install failed: %v", err) } if wrote { t.Errorf("second install rewrote a current block (not byte-stable)") @@ -194,9 +194,9 @@ func TestMarkerOutdatedBlockIsRewritten(t *testing.T) { if classifyMarker(path) != markerOutdated { t.Fatalf("precondition: expected outdated, got %q", classifyMarker(path)) } - wrote, ok := installMarkerFile(path) - if !ok || !wrote { - t.Fatalf("rewrite: wrote=%v ok=%v", wrote, ok) + wrote, err := installMarkerFile(path) + if err != nil || !wrote { + t.Fatalf("rewrite: wrote=%v err=%v", wrote, err) } if classifyMarker(path) != markerCurrent { t.Errorf("state after rewrite = %q, want current", classifyMarker(path)) @@ -217,7 +217,7 @@ func TestMarkerMultiBlockCollapsesToOne(t *testing.T) { if err := os.WriteFile(path, []byte(dup), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); !ok { + if _, err := installMarkerFile(path); err != nil { t.Fatal("install failed") } got, _ := os.ReadFile(path) @@ -236,10 +236,10 @@ func TestMarkerRemoveRoundTrip(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); !ok { + if _, err := installMarkerFile(path); err != nil { t.Fatal("install failed") } - if _, ok := removeMarkerFile(path); !ok { + if _, err := removeMarkerFile(path); err != nil { t.Fatal("remove failed") } got, _ := os.ReadFile(path) @@ -258,7 +258,7 @@ func TestMarkerCRLFPreserved(t *testing.T) { if err := os.WriteFile(path, []byte(crlf), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); !ok { + if _, err := installMarkerFile(path); err != nil { t.Fatal("install failed") } got, _ := os.ReadFile(path) diff --git a/internal/core/ahoy/marker_unclosed_test.go b/internal/core/ahoy/marker_unclosed_test.go index 0eefda6a2..3dd1320b6 100644 --- a/internal/core/ahoy/marker_unclosed_test.go +++ b/internal/core/ahoy/marker_unclosed_test.go @@ -22,7 +22,7 @@ func TestMarkerRefusesToAppendInsideAnUnclosedSpan(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } - if _, ok := installMarkerFile(path); ok { + if _, err := installMarkerFile(path); err == nil { t.Errorf("%s: install reported ok", name) } if got, _ := os.ReadFile(path); !bytes.Equal(got, []byte(original)) { @@ -41,7 +41,7 @@ func TestMarkerRefusesToAppendInsideAnUnclosedSpan(t *testing.T) { if err := os.WriteFile(path, []byte("# Title\n\n```\nunclosed code\n"), 0o644); err != nil { t.Fatal(err) } - if wrote, ok := installMarkerFile(path); !ok || !wrote { - t.Errorf("an H1 above an unclosed fence: wrote=%v ok=%v", wrote, ok) + if wrote, err := installMarkerFile(path); err != nil || !wrote { + t.Errorf("an H1 above an unclosed fence: wrote=%v err=%v", wrote, err) } } diff --git a/internal/core/ahoy/swallowed_writes_test.go b/internal/core/ahoy/swallowed_writes_test.go new file mode 100644 index 000000000..22c804164 --- /dev/null +++ b/internal/core/ahoy/swallowed_writes_test.go @@ -0,0 +1,86 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/identity" +) + +// notesCarryAll reports whether any note carries every one of want. +func notesCarryAll(notes []string, want ...string) bool { + for _, n := range notes { + all := true + for _, w := range want { + if !strings.Contains(n, w) { + all = false + break + } + } + if all { + return true + } + } + return false +} + +// TestIdentityPinFailureIsNoted is iss-2609260057127611's first write: the pin +// step dropped a failed identity.WritePin, and an unset git identity, without a +// word, so a run that recorded no identity said nothing about it. +func TestIdentityPinFailureIsNoted(t *testing.T) { + for name, gitName := range map[string]string{ + "write refused": `Quote "Person"`, // WritePin refuses a double-quote + "identity unset": "", + } { + t.Run(name, func(t *testing.T) { + email := "someone@example.com" + if gitName == "" { + email = "" + } + dir := idGitRepo(t, gitName, email) + a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{ConfigChange: true}, gapPresent: map[string]bool{OptionalPinGapID: true}} + a.stepIdentityPin() + if _, ok, _ := identity.LoadPin(dir); ok { + t.Fatal("precondition: no pin is written") + } + if !notesCarryAll(a.notes, identity.PinRelPath) { + t.Errorf("no note says the identity was not recorded; notes: %v", a.notes) + } + }) + } +} + +// TestGitignoreBlockFailureIsNoted is the second: the visibility step dropped +// applyVisibilityBlock's refusal, so a symlinked .gitignore left the run with +// no fence and no reason. +func TestGitignoreBlockFailureIsNoted(t *testing.T) { + dir := t.TempDir() + if err := os.Symlink(filepath.Join(t.TempDir(), "elsewhere"), filepath.Join(dir, ".gitignore")); err != nil { + t.Fatal(err) + } + a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{ConfigChange: true}} + a.stepVisibility(&InstallConfig{Visibility: "private"}) + if !notesCarryAll(a.notes, ".gitignore", "symlink") { + t.Errorf("no note says the .gitignore block was not written, and why; notes: %v", a.notes) + } +} + +// TestMarkerBlockFailureIsNoted is the third: the marker step dropped a failed +// placement or retraction of abcd's block in a conventions file. +func TestMarkerBlockFailureIsNoted(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"CLAUDE.md", "AGENTS.md"} { + if err := os.Symlink(filepath.Join(t.TempDir(), name), filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } + a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{PluginOwned: true}, markerRetract: []string{"AGENTS.md"}} + a.stepMarker(&InstallConfig{DocsTarget: "claude_md"}) + for _, name := range []string{"CLAUDE.md", "AGENTS.md"} { + if !notesCarryAll(a.notes, name, "symlink") { + t.Errorf("no note says abcd's block in %s was left alone, and why; notes: %v", name, a.notes) + } + } +} From 2bcb291b5ed10b683e7e3648a4c7755c720acb70 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:06:34 +0100 Subject: [PATCH 071/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057127611?= =?UTF-8?q?=20=E2=80=94=20failed=20install=20writes=20are=20noted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057127611 Assisted-by: Claude:claude-opus-5-5 --- ...-ahoy-install-writes-still-swallow-their-errors-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md (63%) diff --git a/.abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md b/.abcd/work/issues/resolved/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md similarity index 63% rename from .abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md rename to .abcd/work/issues/resolved/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md index f3b22ceda..fe503d171 100644 --- a/.abcd/work/issues/open/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md +++ b/.abcd/work/issues/resolved/iss-2609260057127611-three-ahoy-install-writes-still-swallow-their-errors-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/apply.go" +resolution: "stepIdentityPin, stepVisibility and stepMarker each leave a note naming the file and the reason when their write fails or is refused; the marker helpers return the reason as an error." +impact: fix +resolved_by: + commit: "13e5a522" --- Three ahoy install writes still swallow their errors, the class iss-227 fixed for their siblings: the identity pin (stepIdentityPin drops a failed identity.WritePin, and an unset git identity, without a word), the .gitignore visibility block (stepVisibility drops applyVisibilityBlock's error), and the conventions-file marker block (stepMarker drops installMarkerFile and removeMarkerFile failures). A run that wrote none of them reports with no reason. + +## Grounds + +- pursued: no install write fails silently; TestIdentityPinFailureIsNoted, TestGitignoreBlockFailureIsNoted and TestMarkerBlockFailureIsNoted would fail if any of the three dropped its failure again From 29bcf558ea826e75adb900fca7b26e8289a7d791 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:06:51 +0100 Subject: [PATCH 072/107] docs(brief): the ahoy chapter says a refused .gitignore write is noted The visibility step's receipt was described as carrying one extra line, the narrowed-fence note. A block it could not write is now a note too, so the chapter says so, in the same change set as the behaviour. Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/01-ahoy.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 5e6f67fe1..fd94ce028 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -417,9 +417,11 @@ hook as a running one. Two writes deserve their own note. The visibility step rewrites the ignore block under the config-change approval already given, with no confirmation of its own; -its one extra line is a post-hoc note when a public fence had to be narrowed, +its receipt adds a post-hoc note when a public fence had to be narrowed, because an ignore rule cannot untrack committed records, so the reader learns -from the receipt that the committed record tiers stay published (iss-255). And a +from the receipt that the committed record tiers stay published (iss-255). Like +every install write, a block it could not write (a symlinked `.gitignore`, say) +is a note naming the file and the reason, never a silent omission. And a remote URL recorded in the registry carries no credential: it is scrubbed where the identity is derived, scrubbed again as the index is *loaded* so every rewrite drops a credential from every entry rather than only the one being From 67594f9b852aebd222b839451c22007fa0feec76 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:07:39 +0100 Subject: [PATCH 073/107] fix(cli): the stray-store note knows the root under another case strayStoreNotes walked from the working directory to the checkout root by comparing path strings. On a case-insensitive filesystem a directory spelt in another case is the root, but the walk passed it and reported the checkout's own ledger as a second one at ../<root>/.abcd/work/issues. The root is now recognised by identity (os.SameFile), and a genuine stray is named relative to the root as the caller spelt it. Refs: iss-2609260057123452 Assisted-by: Claude:claude-opus-5-5 --- internal/surface/cli/cli.go | 41 +++++++++++++++---- internal/surface/cli/stray_store_case_test.go | 40 ++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 internal/surface/cli/stray_store_case_test.go diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index f444cd4c1..2e22b44f7 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3401,16 +3401,26 @@ func strayStoreNotes(cwd, root, relPath, noun string) []string { if err != nil { top = filepath.Clean(root) } - var notes []string - for dir != top { + // The root is recognised by identity, not spelling: on a case-insensitive + // filesystem the caller's directory can name the root in another case, which + // EvalSymlinks keeps, and a string comparison would walk past the root and + // report its own store as a stray one (iss-2609260057123452). + topInfo, topErr := os.Stat(top) + isTop := func(d string) bool { + if d == top { + return true + } + if topErr != nil { + return false + } + fi, err := os.Stat(d) + return err == nil && os.SameFile(fi, topInfo) + } + var strays []string + for !isTop(dir) { store := filepath.Join(dir, filepath.FromSlash(relPath)) if fi, statErr := os.Stat(store); statErr == nil && fi.IsDir() { - rel, relErr := filepath.Rel(top, store) - if relErr != nil { - rel = store - } - notes = append(notes, indefiniteArticle(noun)+" "+noun+" also exists below the checkout root, at "+filepath.ToSlash(rel)+ - " — this verb addressed the checkout's "+noun+" and left that one untouched; records filed there reach no gate and no release cut") + strays = append(strays, store) } parent := filepath.Dir(dir) if parent == dir { @@ -3418,6 +3428,21 @@ func strayStoreNotes(cwd, root, relPath, noun string) []string { } dir = parent } + // Paths are named relative to the root as the caller spelt it, when the walk + // reached it, so a case-variant spelling does not read as "../<root>/...". + base := top + if isTop(dir) { + base = dir + } + var notes []string + for _, store := range strays { + rel, relErr := filepath.Rel(base, store) + if relErr != nil { + rel = store + } + notes = append(notes, indefiniteArticle(noun)+" "+noun+" also exists below the checkout root, at "+filepath.ToSlash(rel)+ + " — this verb addressed the checkout's "+noun+" and left that one untouched; records filed there reach no gate and no release cut") + } return notes } diff --git a/internal/surface/cli/stray_store_case_test.go b/internal/surface/cli/stray_store_case_test.go new file mode 100644 index 000000000..c92a69a93 --- /dev/null +++ b/internal/surface/cli/stray_store_case_test.go @@ -0,0 +1,40 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/capture" +) + +// TestStrayStoreNotesKnowsTheRootUnderAnotherCase is iss-2609260057123452: on a +// case-insensitive filesystem a working directory spelt with a different case +// from the checkout root is the root, but the walk compared path strings, never +// met the root, and reported the checkout's own ledger as a second one below it. +// A genuine stray store below the root is still reported. +func TestStrayStoreNotesKnowsTheRootUnderAnotherCase(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "CaseRepo") + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(capture.LedgerRelPath)), 0o755); err != nil { + t.Fatal(err) + } + variant := filepath.Join(parent, "caserepo") + if _, err := os.Stat(variant); err != nil { + t.Skip("this filesystem is case-sensitive, so no case-variant spelling reaches the root") + } + + if notes := strayStoreNotes(variant, root, capture.LedgerRelPath, "ledger"); len(notes) != 0 { + t.Errorf("the checkout's own ledger, reached by a case-variant path, is reported as a stray: %v", notes) + } + + sub := filepath.Join(variant, "sub") + if err := os.MkdirAll(filepath.Join(sub, filepath.FromSlash(capture.LedgerRelPath)), 0o755); err != nil { + t.Fatal(err) + } + notes := strayStoreNotes(sub, root, capture.LedgerRelPath, "ledger") + if len(notes) != 1 || !strings.Contains(notes[0], "sub/"+capture.LedgerRelPath) { + t.Errorf("a genuine stray ledger below the root is not reported exactly once: %v", notes) + } +} From 6b050d7d27dbf5f84439a634f58cd0e0fb15ab76 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:07:41 +0100 Subject: [PATCH 074/107] =?UTF-8?q?chore:=20resolve=20iss-2609260057123452?= =?UTF-8?q?=20=E2=80=94=20stray=20note=20knows=20the=20root's=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260057123452 Assisted-by: Claude:claude-opus-5-5 --- ...y-ledger-note-every-capture-verb-prints-reports-the.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md (57%) diff --git a/.abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md b/.abcd/work/issues/resolved/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md similarity index 57% rename from .abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md rename to .abcd/work/issues/resolved/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md index 8fedb1859..83a2eb126 100644 --- a/.abcd/work/issues/open/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md +++ b/.abcd/work/issues/resolved/iss-2609260057123452-the-stray-ledger-note-every-capture-verb-prints-reports-the.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/cli.go" +resolution: "strayStoreNotes recognises the checkout root by file identity rather than spelling, so a case-variant working directory no longer reports the root's own store as a stray; genuine strays are still named." +impact: fix +resolved_by: + commit: "67594f9b" --- The stray-ledger note every capture verb prints reports the checkout's own ledger as a second ledger when the working directory is a case-variant spelling of the checkout on a case-insensitive filesystem (the APFS default): strayStoreNotes compares EvalSymlinks strings, which keep the caller's case, so the walk never meets the root and names ../<root>/.abcd/work/issues as a ledger below the root. + +## Grounds + +- pursued: one checkout has one ledger whatever case the caller types; TestStrayStoreNotesKnowsTheRootUnderAnotherCase would fail on a case-insensitive filesystem if the root's own store were reported again or a real stray were missed (it skips on a case-sensitive one) From dc803336ccdef2fa8c1cb149f485d3cfab31b7b9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:49:35 +0100 Subject: [PATCH 075/107] chore: capture the launchkind review's seven findings The fix round's review of the non-plugin launch lane named a test whose expectation came from the condition it tested, a directory-only export-ignore form the archive listing never matched, and five minors. The duplicate-key finding is deferred to the integration step, where the strict decoder on the lintB lane lands. Refs: iss-2609260149234099 Refs: iss-2609260149238314 Refs: iss-2609260149249835 Refs: iss-2609260149247384 Refs: iss-2609260149240890 Refs: iss-2609260149249724 Refs: iss-2609260149248713 Assisted-by: Claude:claude-opus-5-5 --- ...nditionneedsagreenverify-derives-whether-a.md | 14 ++++++++++++++ ...etree-asks-check-attr-about-each-directory.md | 14 ++++++++++++++ ...d-header-prints-the-host-s-go-toolchain-go.md | 14 ++++++++++++++ ...ee-passes-its-revision-to-git-ls-tree-as-a.md | 14 ++++++++++++++ ...-binary-and-application-behave-identically.md | 14 ++++++++++++++ ...-reader-decodes-with-plain-encoding-json-a.md | 16 ++++++++++++++++ ...step-path-is-contained-lexically-only-so-a.md | 14 ++++++++++++++ 7 files changed, 100 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md create mode 100644 .abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md create mode 100644 .abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md create mode 100644 .abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md create mode 100644 .abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md create mode 100644 .abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md create mode 100644 .abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md diff --git a/.abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md b/.abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md new file mode 100644 index 000000000..3006e797d --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260149234099" +slug: "testthepublishconditionneedsagreenverify-derives-whether-a" +severity: "major" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/scaffold/tagorder_workflow_test.go" +--- + +TestThePublishConditionNeedsAGreenVerify derives whether a source is the reusable gate from the condition under test (gate := strings.Contains(cond, "inputs.publish")), so deleting the '&& inputs.publish' clause from the gate profile's build and release jobs in release.yml.tmpl leaves the suite green; a caller that passes publish: false with contents: write would then get a Release from the gate before its own build. The expectation must come from the profile that rendered the source. diff --git a/.abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md b/.abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md new file mode 100644 index 000000000..f13dd70c4 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260149238314" +slug: "gitutil-archivetree-asks-check-attr-about-each-directory" +severity: "minor" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/gitutil/archive.go" +--- + +gitutil.ArchiveTree asks check-attr about each directory without a trailing slash, so the directory-only export-ignore form (dir/ export-ignore, at the root or in a nested .gitattributes) never matches and the non-plugin launch preview lists and scans files git archive HEAD omits; asking both dir and dir/ is also wrong, since archive asks a directory as dir/ alone and dir export-ignore followed by dir/ -export-ignore keeps it. diff --git a/.abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md b/.abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md new file mode 100644 index 000000000..2eb8f29f5 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260149240890" +slug: "the-launch-scaffold-header-prints-the-host-s-go-toolchain-go" +severity: "minor" +category: "ux" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/scaffold.go" +--- + +The launch scaffold header prints the host's Go toolchain (go 1.25) for a repository that declares a non-Go application and carries no go.mod; the Go version is only meaningful when go.mod exists. diff --git a/.abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md b/.abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md new file mode 100644 index 000000000..ec0cf5867 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260149247384" +slug: "gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a" +severity: "nitpick" +category: "security" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/gitutil/archive.go" +--- + +gitutil.ArchiveTree passes its revision to git ls-tree as a positional argument without --end-of-options, so an option-shaped revision is parsed as a flag; the sole caller passes HEAD today. diff --git a/.abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md b/.abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md new file mode 100644 index 000000000..2451edaa1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260149248713" +slug: "the-artefact-kinds-binary-and-application-behave-identically" +severity: "minor" +category: "documentation" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/kind.go" +--- + +The artefact kinds binary and application behave identically in every launch verb (KindBinary is referenced nowhere outside the reader, and the Go leg keys on go.mod), and neither the launch page nor the intent's Decisions says so, so a reader expects the choice to change what the release does. diff --git a/.abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md b/.abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md new file mode 100644 index 000000000..70a37e5f8 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2609260149249724" +slug: "the-artefact-kind-reader-decodes-with-plain-encoding-json-a" +severity: "minor" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/artefact.go" +deferred_after: "v0.10.0" +deferral_reason: "deferred to the integration step (run A, 2026-09-26): the strict duplicate-key decoder jsonstrict lives on the unmerged lintB lane and copying it here would fork it; once lintB lands, the artefact-kind reader (top level and lockstep entries) reroutes its decode through jsonstrict, refusing duplicate keys and case-folded field names, and this record is resolved there" +--- + +The artefact-kind reader decodes with plain encoding/json: a duplicate top-level key takes the last value ({"kind":"wasm","kind":"binary"} reads as binary) and lockstep object entries decode case-insensitively ({"PATH":..,"JSON_POINTER":..} accepted) while the top level refuses Kind. diff --git a/.abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md b/.abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md new file mode 100644 index 000000000..0d62b1da4 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260149249835" +slug: "a-declared-lockstep-path-is-contained-lexically-only-so-a" +severity: "minor" +category: "security" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane launchkind)" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/launch/lockstep.go" +--- + +A declared lockstep path is contained lexically only, so a committed symlink inside the repository that points outside it is read through: launch reads a file out of the tree and surfaces its parse error or the version value at the pointer. The path must be resolved and a target outside the repository refused. From 98a77f3d9f05cb686357817f701563cac59f2392 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:49:41 +0100 Subject: [PATCH 076/107] test(scaffold): take the gate expectation from the profile, not the condition TestThePublishConditionNeedsAGreenVerify decided whether a source was the reusable gate by looking for inputs.publish in the very condition it evaluated, so a template that lost the clause was expected not to carry it and the suite stayed green. The gate flag now comes from the profile that rendered the source (the committed workflow is not the gate), and the build job's condition is evaluated under the same rule, less its own result. Watched red on a scratch copy of the tree: deleting the clause from the build job alone fails 8 combinations, from the release job alone 2; the previous test passed both mutants. Refs: iss-2609260149234099 Assisted-by: Claude:claude-opus-5-5 --- .../launch/scaffold/tagorder_workflow_test.go | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/core/launch/scaffold/tagorder_workflow_test.go b/internal/core/launch/scaffold/tagorder_workflow_test.go index e6d5a87a1..9bed17191 100644 --- a/internal/core/launch/scaffold/tagorder_workflow_test.go +++ b/internal/core/launch/scaffold/tagorder_workflow_test.go @@ -111,23 +111,34 @@ func TestThePublishConditionNeedsAGreenVerify(t *testing.T) { if err != nil { t.Fatalf("read committed %s: %v", ReleaseYMLPath, err) } - sources := map[string]string{ReleaseYMLPath: string(committed)} + // Whether a source is the reusable gate comes from the profile that + // rendered it, never from the condition under test: a condition that lost + // its `inputs.publish` clause would otherwise be expected not to carry it. + type source struct { + wf string + gate bool + } + sources := map[string]source{ReleaseYMLPath: {wf: string(committed), gate: false}} for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main"), GateSubstitutions("main", "")} { rendered, err := Render(subs) if err != nil { t.Fatal(err) } - sources[fmt.Sprintf("release.yml.tmpl (Abcd=%v, Gate=%v)", subs.Abcd, subs.Gate)] = string(rendered.ReleaseYML) + sources[fmt.Sprintf("release.yml.tmpl (Abcd=%v, Gate=%v)", subs.Abcd, subs.Gate)] = source{wf: string(rendered.ReleaseYML), gate: subs.Gate} } results := []string{"success", "failure", "cancelled", "skipped"} - for where, wf := range sources { + for where, src := range sources { + wf, gate := src.wf, src.gate cond := jobIf(t, jobSection(t, wf, "release"), where) // A managed profile publishes only what its named build job built // (iss-2608270559310755), and the gate publishes only when its caller // asks; abcd's own profile has neither, and its condition reads neither. hasBuild := strings.Contains(wf, "\n build:\n") - gate := strings.Contains(cond, "inputs.publish") + var buildCond string + if hasBuild { + buildCond = jobIf(t, jobSection(t, wf, "build"), where+" build job") + } for _, event := range []string{"push", "workflow_dispatch"} { for _, verify := range results { for _, tag := range results { @@ -157,6 +168,22 @@ func TestThePublishConditionNeedsAGreenVerify(t *testing.T) { "with its tag made or not asked for, on an uncancelled non-rehearsal run", where, cond, got, event, verify, tag, build, publish, cancelled, want) } + if buildCond == "" { + continue + } + // The build job obeys the same rule less its own + // result: a caller that asked for no publish gets + // no build either. + gotBuild, err := actionsexpr.EvalIf(buildCond, ctx) + if err != nil { + t.Fatalf("%s: cannot evaluate the build job's if %q: %v", where, buildCond, err) + } + wantBuild := event != "workflow_dispatch" && !cancelled && verify == "success" && + (tag == "success" || tag == "skipped") && (!gate || publish) + if gotBuild != wantBuild { + t.Errorf("%s: the build job's if %q is %v for event=%s verify=%s tag=%s publish=%v "+ + "cancelled=%v, want %v", where, buildCond, gotBuild, event, verify, tag, publish, cancelled, wantBuild) + } } } } From 77af2044c936297377d83ebfe94302b6f43ee0a6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:49:47 +0100 Subject: [PATCH 077/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149234099?= =?UTF-8?q?=20=E2=80=94=20the=20gate's=20publish=20clause=20is=20held=20by?= =?UTF-8?q?=20the=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260149234099 Assisted-by: Claude:claude-opus-5-5 --- ...publishconditionneedsagreenverify-derives-whether-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md (66%) diff --git a/.abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md b/.abcd/work/issues/resolved/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md similarity index 66% rename from .abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md rename to .abcd/work/issues/resolved/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md index 3006e797d..6e487d559 100644 --- a/.abcd/work/issues/open/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md +++ b/.abcd/work/issues/resolved/iss-2609260149234099-testthepublishconditionneedsagreenverify-derives-whether-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/core/launch/scaffold/tagorder_workflow_test.go" +resolution: "The publish-condition test takes the gate flag from the rendering profile and evaluates the build job too; deleting either inputs.publish clause now fails it." +impact: internal +resolved_by: + commit: "98a77f3d" --- TestThePublishConditionNeedsAGreenVerify derives whether a source is the reusable gate from the condition under test (gate := strings.Contains(cond, "inputs.publish")), so deleting the '&& inputs.publish' clause from the gate profile's build and release jobs in release.yml.tmpl leaves the suite green; a caller that passes publish: false with contents: write would then get a Release from the gate before its own build. The expectation must come from the profile that rendered the source. + +## Grounds + +- pursued: a gate-profile template missing '&& inputs.publish' on its build or release job fails TestThePublishConditionNeedsAGreenVerify; a mutant that removes either clause and still passes would show it wrong From 6401b0be10806407708af9c8b5c615216d982c60 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:50:01 +0100 Subject: [PATCH 078/107] fix(gitutil): ask check-attr about a directory the way git archive does ArchiveTree asked check-attr about each directory without a trailing slash, so the directory-only pattern form (dir/ export-ignore), at the root or in a nested .gitattributes, never matched: the non-plugin launch preview listed and scanned files the tag never ships, and said export-ignore was honoured. Directories are now asked as dir/ alone, the form archive itself asks. Asking dir as well, as the review proposed, is wrong the other way: with 'dir export-ignore' then 'dir/ -export-ignore' archive keeps the directory, and the union would drop files the tag does ship from the scan. The new test holds ArchiveTree to the member list of git archive HEAD over file, bare-directory, directory-only (root and nested), negated-glob, directory-only-names-a-file and re-inclusion forms; it was watched red against both the original and the union implementation. Refs: iss-2609260149238314 Assisted-by: Claude:claude-opus-5-5 --- internal/gitutil/archive.go | 16 +++-- internal/gitutil/archive_test.go | 120 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 internal/gitutil/archive_test.go diff --git a/internal/gitutil/archive.go b/internal/gitutil/archive.go index f458574bf..f2fd4d1da 100644 --- a/internal/gitutil/archive.go +++ b/internal/gitutil/archive.go @@ -19,7 +19,13 @@ type ArchiveEntry struct { // trusted by git, so the listing is built from the two commands that run no // configured program: `ls-tree` for the committed files, and `check-attr` for the // export-ignore attribute archive honours, asked of each file and of every -// directory above it (an ignored directory drops everything beneath it). +// directory above it (an ignored directory drops everything beneath it). A +// directory is asked as `dir/`, the form archive itself asks: only a path that +// ends in a slash matches the directory-only pattern (`dir/ export-ignore`), a +// bare pattern (`dir export-ignore`) matches it too, and where the two forms +// disagree (`dir export-ignore` then `dir/ -export-ignore`) the answer for +// `dir/` is the one archive acts on. Asking `dir` as well would drop a +// directory archive keeps, and the scan would miss files the tag ships. // Submodules are skipped: archive carries no submodule content. // // Attributes are read from the index (--cached), the committed view, so an @@ -50,10 +56,12 @@ func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { return nil, nil } + // Each file is asked as itself; each directory above one is asked as `dir/`. candidates := map[string]struct{}{} for _, e := range entries { - for p := e.Path; p != "." && p != "/" && p != ""; p = path.Dir(p) { - candidates[p] = struct{}{} + candidates[e.Path] = struct{}{} + for p := path.Dir(e.Path); p != "." && p != "/" && p != ""; p = path.Dir(p) { + candidates[p+"/"] = struct{}{} } } list := make([]string, 0, len(candidates)) @@ -71,7 +79,7 @@ func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { // -z emits three fields per record: path, attribute, value. for i := 0; i+2 < len(fields); i += 3 { if fields[i+2] == "set" { - ignored[fields[i]] = struct{}{} + ignored[strings.TrimSuffix(fields[i], "/")] = struct{}{} } } kept := entries[:0] diff --git a/internal/gitutil/archive_test.go b/internal/gitutil/archive_test.go new file mode 100644 index 000000000..80194a600 --- /dev/null +++ b/internal/gitutil/archive_test.go @@ -0,0 +1,120 @@ +package gitutil_test + +import ( + "archive/tar" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// TestArchiveTreeAgreesWithGitArchive holds ArchiveTree to the tree `git +// archive HEAD` actually writes, over every export-ignore form a repository can +// declare: a file, a directory named without a slash, the directory-only form +// `dir/` (at the root and in a nested .gitattributes), a negated glob, a +// directory-only pattern whose name is a file, which must not match it, and a +// directory ignored by name but re-included by the directory-only form, which +// archive keeps because it asks a directory's attributes as `dir/` alone. +func TestArchiveTreeAgreesWithGitArchive(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("keep.txt", "kept\n") + r.Write("ignored.txt", "file pattern\n") + r.Write("plaindir/a.txt", "directory named without a slash\n") + r.Write("slashdir/c.txt", "directory-only pattern\n") + r.Write("slashdir/deeper/e.txt", "beneath a directory-only pattern\n") + r.Write("nested/keep.txt", "kept\n") + r.Write("nested/sub/d.txt", "nested directory-only pattern\n") + r.Write("nested/.gitattributes", "sub/ export-ignore\n") + r.Write("neg/x.txt", "negated glob drops this\n") + r.Write("neg/keep.txt", "negation keeps this\n") + r.Write("notadir", "a file a directory-only pattern names\n") + r.Write("reincluded/f.txt", "ignored by name, re-included by the directory-only form\n") + r.Write(".gitattributes", strings.Join([]string{ + "ignored.txt export-ignore", + "plaindir export-ignore", + "slashdir/ export-ignore", + "neg/* export-ignore", + "neg/keep.txt -export-ignore", + "notadir/ export-ignore", + "reincluded export-ignore", + "reincluded/ -export-ignore", + }, "\n")+"\n") + if err := os.Symlink("keep.txt", filepath.Join(r.Root(), "link.txt")); err != nil { + t.Fatal(err) + } + r.Commit("fixture") + + entries, err := gitutil.ArchiveTree(r.Root(), "HEAD") + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range entries { + got = append(got, e.Path) + } + sort.Strings(got) + + want := archivedFiles(t, r) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("ArchiveTree disagrees with git archive HEAD:\n got: %v\nwant: %v", got, want) + } + // The fixture must exercise what it claims: the directory-only forms drop + // their trees, and a directory-only pattern leaves a file of that name. + for _, p := range []string{"slashdir/c.txt", "slashdir/deeper/e.txt", "nested/sub/d.txt"} { + for _, g := range got { + if g == p { + t.Errorf("%s is export-ignored by a directory-only pattern but was listed", p) + } + } + } + if !contains(got, "reincluded/f.txt") { + t.Errorf("reincluded/ -export-ignore re-includes the directory in the archive; it must be listed (got %v)", got) + } + if !contains(got, "notadir") { + t.Errorf("notadir is a file; the directory-only pattern notadir/ must not drop it (got %v)", got) + } +} + +// archivedFiles lists the non-directory members of `git archive HEAD`. +func archivedFiles(t *testing.T, r *gittest.Repo) []string { + t.Helper() + out := filepath.Join(t.TempDir(), "head.tar") + r.Git("archive", "--format=tar", "-o", out, "HEAD") + f, err := os.Open(out) + if err != nil { + t.Fatal(err) + } + defer f.Close() + var files []string + tr := tar.NewReader(f) + for { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + if h.Typeflag == tar.TypeDir || h.Typeflag == tar.TypeXGlobalHeader { + continue + } + files = append(files, h.Name) + } + sort.Strings(files) + return files +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} From d8ad3d2751562b81bbac351910fd63559d8ad388 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:50:10 +0100 Subject: [PATCH 079/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149238314?= =?UTF-8?q?=20=E2=80=94=20the=20archive=20listing=20honours=20dir/=20expor?= =?UTF-8?q?t-ignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260149238314 Assisted-by: Claude:claude-opus-5-5 --- ...il-archivetree-asks-check-attr-about-each-directory.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md (63%) diff --git a/.abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md b/.abcd/work/issues/resolved/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md similarity index 63% rename from .abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md rename to .abcd/work/issues/resolved/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md index f13dd70c4..7aaafe828 100644 --- a/.abcd/work/issues/open/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md +++ b/.abcd/work/issues/resolved/iss-2609260149238314-gitutil-archivetree-asks-check-attr-about-each-directory.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/gitutil/archive.go" +resolution: "ArchiveTree asks each directory as dir/, the form git archive asks, and a test holds its listing to git archive HEAD over every export-ignore form." +impact: fix +resolved_by: + commit: "6401b0be" --- gitutil.ArchiveTree asks check-attr about each directory without a trailing slash, so the directory-only export-ignore form (dir/ export-ignore, at the root or in a nested .gitattributes) never matches and the non-plugin launch preview lists and scans files git archive HEAD omits; asking both dir and dir/ is also wrong, since archive asks a directory as dir/ alone and dir export-ignore followed by dir/ -export-ignore keeps it. + +## Grounds + +- pursued: ArchiveTree lists exactly the non-directory members git archive HEAD writes, directory-only export-ignore forms and dir/ re-inclusion included; a .gitattributes form under which the two listings differ would show it wrong From af2d21a0c288adee868bd398205350de133fa23c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:50:12 +0100 Subject: [PATCH 080/107] fix(gitutil): end the ls-tree options before the archive revision ArchiveTree handed its revision to git ls-tree positionally, so an option-shaped revision was parsed as a flag. --end-of-options now precedes it, and a test holds an option-shaped revision to git's object-name refusal rather than its usage text (watched red: the usage text came back before the change). The sole caller passes HEAD. Refs: iss-2609260149247384 Assisted-by: Claude:claude-opus-5-5 --- internal/gitutil/archive.go | 2 +- internal/gitutil/archive_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/gitutil/archive.go b/internal/gitutil/archive.go index f2fd4d1da..30ce26000 100644 --- a/internal/gitutil/archive.go +++ b/internal/gitutil/archive.go @@ -33,7 +33,7 @@ type ArchiveEntry struct { // git could not list — no commit at rev, not a repository, git absent — and is // never reported as an empty tree. func ArchiveTree(root, rev string) ([]ArchiveEntry, error) { - out, err := isolatedGit(root, "ls-tree", "-r", "-z", "--full-tree", rev).Output() + out, err := isolatedGit(root, "ls-tree", "-r", "-z", "--full-tree", "--end-of-options", rev).Output() if err != nil { return nil, withStderr(err) } diff --git a/internal/gitutil/archive_test.go b/internal/gitutil/archive_test.go index 80194a600..ed399b5d6 100644 --- a/internal/gitutil/archive_test.go +++ b/internal/gitutil/archive_test.go @@ -81,6 +81,22 @@ func TestArchiveTreeAgreesWithGitArchive(t *testing.T) { } } +// TestArchiveTreeReadsAnOptionShapedRevisionAsARevision holds the positional +// revision behind --end-of-options: a rev that looks like an option is looked +// up as an object name and refused as one, never parsed as a flag. +func TestArchiveTreeReadsAnOptionShapedRevisionAsARevision(t *testing.T) { + r := gittest.NewRepo(t) + r.Write("a.txt", "a\n") + r.Commit("fixture") + _, err := gitutil.ArchiveTree(r.Root(), "--name-only") + if err == nil { + t.Fatal("an option-shaped revision names no commit and must be refused") + } + if strings.Contains(err.Error(), "usage:") || !strings.Contains(err.Error(), "--name-only") { + t.Errorf("the revision was parsed as an option, not looked up as an object name: %v", err) + } +} + // archivedFiles lists the non-directory members of `git archive HEAD`. func archivedFiles(t *testing.T, r *gittest.Repo) []string { t.Helper() From 5670effd08f0a495eece55ec6852ec4ce7893517 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:50:18 +0100 Subject: [PATCH 081/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149247384?= =?UTF-8?q?=20=E2=80=94=20the=20archive=20revision=20is=20never=20an=20opt?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260149247384 Assisted-by: Claude:claude-opus-5-5 --- ...archivetree-passes-its-revision-to-git-ls-tree-as-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md (64%) diff --git a/.abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md b/.abcd/work/issues/resolved/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md similarity index 64% rename from .abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md rename to .abcd/work/issues/resolved/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md index ec0cf5867..a1a61a617 100644 --- a/.abcd/work/issues/open/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md +++ b/.abcd/work/issues/resolved/iss-2609260149247384-gitutil-archivetree-passes-its-revision-to-git-ls-tree-as-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/gitutil/archive.go" +resolution: "ArchiveTree puts --end-of-options before the positional revision." +impact: internal +resolved_by: + commit: "af2d21a0" --- gitutil.ArchiveTree passes its revision to git ls-tree as a positional argument without --end-of-options, so an option-shaped revision is parsed as a flag; the sole caller passes HEAD today. + +## Grounds + +- pursued: an option-shaped revision reaches git ls-tree as an object name and is refused as one; git printing ls-tree usage for such a revision would show it wrong From c06885988eab9632f1756705262cb904f3e10e3e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:51:47 +0100 Subject: [PATCH 082/107] fix(launch): contain lockstep reads by what the path resolves to Every repo-rooted document the lockstep checks read (the primary manifest, the marketplace manifest and each declared lockstep file) was joined onto the root and opened, so the containment ValidRelPath gives was lexical: a committed symlink inside the repository pointing out of it was read through, and its value or parse error surfaced in the result. The reads now go through an os.Root opened on the repository, which follows a symlink that stays inside and refuses one that leaves. CheckLockstep's primary and marketplace reads carried the same shape and move with it (pre-existing is not a defence). Watched red: the escaping declared file reported the outside value as a drift, the escaping primary passed the declared check, and the plugin check read it through. Refs: iss-2609260149249835 Assisted-by: Claude:claude-opus-5-5 --- internal/core/launch/lockstep.go | 31 +++++++++-- .../core/launch/lockstep_declared_test.go | 51 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/internal/core/launch/lockstep.go b/internal/core/launch/lockstep.go index 748d2b935..bd987f602 100644 --- a/internal/core/launch/lockstep.go +++ b/internal/core/launch/lockstep.go @@ -60,11 +60,11 @@ func CheckLockstep(tree LockstepTree, repoRoot, versionLocationPath string) Lock return unreadable(res, verr) } - primaryDoc, err := loadJSON(filepath.Join(repoRoot, primaryPath)) + primaryDoc, err := loadRepoJSON(repoRoot, primaryPath) if err != nil { return unreadable(res, "primary manifest not readable: "+err.Error()) } - marketplace, err := loadJSON(filepath.Join(repoRoot, marketplaceFile)) + marketplace, err := loadRepoJSON(repoRoot, marketplaceFile) if err != nil { return unreadable(res, "marketplace.json not readable: "+err.Error()) } @@ -104,6 +104,29 @@ func loadJSON(path string) (any, error) { return v, nil } +// loadRepoJSON reads a repo-relative JSON document through an os.Root opened on +// repoRoot, so the read is contained by what the path resolves to rather than by +// its spelling: ValidRelPath is lexical, and a committed symlink inside the +// repository that points out of it would otherwise be read through. A symlink +// that stays inside the repository still reads; one that leaves it is an error, +// and the document outside is never read. +func loadRepoJSON(repoRoot, rel string) (any, error) { + root, err := os.OpenRoot(repoRoot) + if err != nil { + return nil, pathFreeError(err) + } + defer root.Close() + data, err := root.ReadFile(filepath.FromSlash(rel)) + if err != nil { + return nil, pathFreeError(err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + return v, nil +} + // pathFreeError strips the filesystem path from an os.PathError, leaving only the // underlying cause. The lockstep detail names the file itself ("version-location.json // not readable: ..."), and the dry-run report carrying that detail is a SUCCESS @@ -319,7 +342,7 @@ func CheckDeclaredLockstep(tree LockstepTree, repoRoot, versionLocationPath stri if verr != "" { return unreadable(res, verr) } - primaryDoc, err := loadJSON(filepath.Join(repoRoot, primaryPath)) + primaryDoc, err := loadRepoJSON(repoRoot, primaryPath) if err != nil { return unreadable(res, "primary manifest "+primaryPath+" not readable: "+err.Error()) } @@ -330,7 +353,7 @@ func CheckDeclaredLockstep(tree LockstepTree, repoRoot, versionLocationPath stri } secondaries := make([]located, 0, len(files)) for _, f := range files { - doc, err := loadJSON(filepath.Join(repoRoot, filepath.FromSlash(f.Path))) + doc, err := loadRepoJSON(repoRoot, f.Path) if err != nil { return unreadable(res, "declared lockstep file "+f.Path+" not readable: "+err.Error()) } diff --git a/internal/core/launch/lockstep_declared_test.go b/internal/core/launch/lockstep_declared_test.go index 67963fa80..39ae526f9 100644 --- a/internal/core/launch/lockstep_declared_test.go +++ b/internal/core/launch/lockstep_declared_test.go @@ -1,6 +1,7 @@ package launch import ( + "os" "path/filepath" "strings" "testing" @@ -83,3 +84,53 @@ func TestDeclaredLockstepWithNothingDeclaredIsAnHonestPass(t *testing.T) { t.Fatalf("result %+v, want unreadable naming version-location.json", res) } } + +// A declared path is contained by what it resolves to, not by its spelling: a +// committed symlink inside the repository that points out of it is refused as +// unreadable, and the value outside is never read. A symlink that stays inside +// the repository reads through. +func TestDeclaredLockstepRefusesASymlinkThatLeavesTheRepository(t *testing.T) { + outside := t.TempDir() + writeFile(t, outside, "meta.json", `{"version":"9.9.9"}`) + root := declaredLockstepRepo(t, `{"version":"1.2.3"}`, `{"version":"1.2.3"}`) + if err := os.Symlink(filepath.Join(outside, "meta.json"), filepath.Join(root, "app", "escape.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("meta.json", filepath.Join(root, "app", "inside.json")); err != nil { + t.Fatal(err) + } + vl := filepath.Join(root, versionLocationRelPath) + + res := CheckDeclaredLockstep(TreePublic, root, vl, []LockstepFile{{Path: "app/escape.json"}}) + if !res.Unreadable || res.ExitCode != 2 || !strings.Contains(res.Detail, "app/escape.json") { + t.Fatalf("result %+v, want unreadable naming app/escape.json", res) + } + if strings.Contains(res.Detail, "9.9.9") || strings.Contains(res.Detail, outside) { + t.Errorf("the refusal carries what lies outside the repository: %s", res.Detail) + } + + res = CheckDeclaredLockstep(TreePublic, root, vl, []LockstepFile{{Path: "app/inside.json"}}) + if !res.OK { + t.Errorf("a symlink that stays inside the repository must read through: %+v", res) + } +} + +// The primary manifest is held the same way, by both lockstep checks. +func TestLockstepRefusesAPrimaryManifestThatLeavesTheRepository(t *testing.T) { + outside := t.TempDir() + writeFile(t, outside, "version.json", `{"version":"1.2.3"}`) + root := t.TempDir() + writeFile(t, root, versionLocationRelPath, `{"outcome":"accept","blocked":false,"manifest_path":"version.json","json_pointer":"/version"}`) + if err := os.Symlink(filepath.Join(outside, "version.json"), filepath.Join(root, "version.json")); err != nil { + t.Fatal(err) + } + vl := filepath.Join(root, versionLocationRelPath) + for name, res := range map[string]LockstepResult{ + "CheckDeclaredLockstep": CheckDeclaredLockstep(TreePublic, root, vl, nil), + "CheckLockstep": CheckLockstep(TreePublic, root, vl), + } { + if !res.Unreadable || res.ExitCode != 2 || !strings.Contains(res.Detail, "primary manifest") { + t.Errorf("%s: result %+v, want the primary manifest unreadable", name, res) + } + } +} From 0127bd39ab25ad01d5e9b3f60eb2f5c8e1928d73 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:51:50 +0100 Subject: [PATCH 083/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149249835?= =?UTF-8?q?=20=E2=80=94=20lockstep=20never=20reads=20out=20of=20the=20repo?= =?UTF-8?q?sitory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260149249835 Assisted-by: Claude:claude-opus-5-5 --- ...ared-lockstep-path-is-contained-lexically-only-so-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md (60%) diff --git a/.abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md b/.abcd/work/issues/resolved/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md similarity index 60% rename from .abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md rename to .abcd/work/issues/resolved/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md index 0d62b1da4..af0f0ee56 100644 --- a/.abcd/work/issues/open/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md +++ b/.abcd/work/issues/resolved/iss-2609260149249835-a-declared-lockstep-path-is-contained-lexically-only-so-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/core/launch/lockstep.go" +resolution: "The lockstep checks read every repo-rooted document through an os.Root, so a symlink out of the repository is refused and one inside still reads." +impact: fix +resolved_by: + commit: "c0688598" --- A declared lockstep path is contained lexically only, so a committed symlink inside the repository that points outside it is read through: launch reads a file out of the tree and surfaces its parse error or the version value at the pointer. The path must be resolved and a target outside the repository refused. + +## Grounds + +- pursued: a declared lockstep file or primary manifest that is a symlink out of the repository makes the check unreadable without reading the outside value; a lockstep read that still opens a joined path would show it wrong From bee9cc307d5e740d60a590307c1a4bd4b62f0e3c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:54:47 +0100 Subject: [PATCH 084/107] fix(launch): name a Go toolchain in the scaffold report only for a Go module The scaffold header printed the host default (go 1.25) for a repository with no go.mod, a toolchain nothing in its release resolves. GoVersion is now empty for such a repository, go_version is omitted from --json, and the header names the kind and branch alone. A Go module still reports its go directive. Watched red: the core test saw go "1.25" and a go_version key for an application with no go.mod; the CLI test saw "go 1.25" in the header of a declared binary with none. Refs: iss-2609260149240890 Assisted-by: Claude:claude-opus-5-5 --- internal/core/launch/scaffold/kind_test.go | 34 ++++++++++++++++++++++ internal/core/launch/scaffold/scaffold.go | 7 ++++- internal/surface/cli/launch_kind_test.go | 14 +++++++++ internal/surface/cli/scaffold.go | 10 +++++-- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/internal/core/launch/scaffold/kind_test.go b/internal/core/launch/scaffold/kind_test.go index 791d23df0..fb9fbe6d4 100644 --- a/internal/core/launch/scaffold/kind_test.go +++ b/internal/core/launch/scaffold/kind_test.go @@ -1,6 +1,7 @@ package scaffold import ( + "encoding/json" "errors" "os" "path/filepath" @@ -263,3 +264,36 @@ func TestBareReleaseIsGatePlumbingPlusANamedEmptyBuildJob(t *testing.T) { } } } + +// The Go toolchain is reported only for a repository that is a Go module: its +// workflows point setup-go at go.mod, so without one there is no toolchain to +// name, and a report of the host's default would describe nothing the release +// uses. +func TestScaffoldReportsAGoVersionOnlyForAGoModule(t *testing.T) { + dir := kindRepo(t, "application") + if err := os.Remove(filepath.Join(dir, "go.mod")); err != nil { + t.Fatal(err) + } + rep, err := Scaffold(Request{RepoRoot: dir}) + if err != nil { + t.Fatalf("scaffold: %v", err) + } + if rep.GoVersion != "" { + t.Errorf("a repository with no go.mod reported go %q", rep.GoVersion) + } + data, err := json.Marshal(rep) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"go_version"`) { + t.Errorf("the JSON report names a go version for a repository with no go.mod: %s", data) + } + + rep, err = Scaffold(Request{RepoRoot: kindRepo(t, "application")}) + if err != nil { + t.Fatalf("scaffold: %v", err) + } + if rep.GoVersion != "1.22" { + t.Errorf("a Go module reported go %q, want its go directive 1.22", rep.GoVersion) + } +} diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index 18b33cc21..10d55ac08 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -76,7 +76,9 @@ type Report struct { // into them: they point setup-go at go.mod, so this reports the go directive // the run read. It is reported because an adopter should see which toolchain // their release lane is about to use, and see it before the first tag. - GoVersion string `json:"go_version"` + // Empty, and absent from the JSON, for a repository with no go.mod: there is + // no toolchain its release resolves, so none is named. + GoVersion string `json:"go_version,omitempty"` // CIChecks are the managed repo's own pull-request check names the // scaffolded files were wired to (DeriveCIChecks); empty when none was found. CIChecks []string `json:"ci_checks"` @@ -126,6 +128,9 @@ func Scaffold(req Request) (Report, error) { subs = GateSubstitutions(branch, own) } subs.GoModule = isGoModule(req.RepoRoot) + if !subs.GoModule { + goVersion = "" + } subs.CIChecks = DeriveCIChecks(req.RepoRoot) if subs.CIChecks == nil { subs.CIChecks = []string{} // --json reports an empty list, never null diff --git a/internal/surface/cli/launch_kind_test.go b/internal/surface/cli/launch_kind_test.go index 6ba1247a3..cf8edc84d 100644 --- a/internal/surface/cli/launch_kind_test.go +++ b/internal/surface/cli/launch_kind_test.go @@ -144,3 +144,17 @@ func TestLaunchScaffoldForABinaryWithItsOwnReleaseWorkflowPrintsTheStanza(t *tes t.Errorf("the repository's own release workflow changed:\n%s", got) } } + +// The scaffold header names a Go toolchain only for a Go module; a declared +// binary with no go.mod has none to name. +func TestLaunchScaffoldHeaderNamesNoGoToolchainWithoutGoMod(t *testing.T) { + r := binaryShipFixture(t) + out, err := shipIn(t, r, "launch", "scaffold") + if err != nil { + t.Fatalf("scaffold: %v\n%s", err, out) + } + header, _, _ := strings.Cut(string(out), "\n") + if !strings.Contains(header, "kind binary, branch ") || strings.Contains(header, "go ") { + t.Errorf("the scaffold header for a repository with no go.mod: %q, want the kind and branch and no go toolchain", header) + } +} diff --git a/internal/surface/cli/scaffold.go b/internal/surface/cli/scaffold.go index f880eeef7..712754993 100644 --- a/internal/surface/cli/scaffold.go +++ b/internal/surface/cli/scaffold.go @@ -86,8 +86,14 @@ func renderScaffold(w io.Writer, rep scaffold.Report, blocked bool) { case rep.NoOp: verdict = "no-op (already current)" } - fmt.Fprintf(w, "abcd launch scaffold — %s (kind %s, branch %s, go %s)\n", - verdict, termsafe.Sanitize(string(rep.Kind)), termsafe.Sanitize(rep.DefaultBranch), termsafe.Sanitize(rep.GoVersion)) + // The toolchain is named only for a Go module; a repository with no go.mod + // resolves none (the report leaves GoVersion empty). + toolchain := "" + if rep.GoVersion != "" { + toolchain = ", go " + termsafe.Sanitize(rep.GoVersion) + } + fmt.Fprintf(w, "abcd launch scaffold — %s (kind %s, branch %s%s)\n", + verdict, termsafe.Sanitize(string(rep.Kind)), termsafe.Sanitize(rep.DefaultBranch), toolchain) if len(rep.CIChecks) > 0 { fmt.Fprintf(w, " merge gate: %s (require these on %s)\n", termsafe.Sanitize(strings.Join(rep.CIChecks, ", ")), termsafe.Sanitize(rep.DefaultBranch)) From 0baaff4a05edbfe410db7718c547bed80fd71294 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:54:50 +0100 Subject: [PATCH 085/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149240890?= =?UTF-8?q?=20=E2=80=94=20no=20Go=20toolchain=20named=20without=20go.mod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260149240890 Assisted-by: Claude:claude-opus-5-5 --- ...h-scaffold-header-prints-the-host-s-go-toolchain-go.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md (59%) diff --git a/.abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md b/.abcd/work/issues/resolved/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md similarity index 59% rename from .abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md rename to .abcd/work/issues/resolved/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md index 2eb8f29f5..0d0c9e394 100644 --- a/.abcd/work/issues/open/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md +++ b/.abcd/work/issues/resolved/iss-2609260149240890-the-launch-scaffold-header-prints-the-host-s-go-toolchain-go.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/scaffold.go" +resolution: "The scaffold report and header name a Go toolchain only when go.mod exists." +impact: fix +resolved_by: + commit: "bee9cc30" --- The launch scaffold header prints the host's Go toolchain (go 1.25) for a repository that declares a non-Go application and carries no go.mod; the Go version is only meaningful when go.mod exists. + +## Grounds + +- pursued: launch scaffold on a repository with no go.mod prints no go toolchain and its --json carries no go_version, while a Go module still reports its go directive; a header naming go for a repository with no go.mod would show it wrong From 8740a2d9c5e810d90bad4f809cc673cb480b1d80 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:55:13 +0100 Subject: [PATCH 086/107] docs(launch): say binary and application behave identically The launch page and the brief's launch chapter listed the two non-plugin kinds with different descriptions and never said that no verb tells them apart: the preview, lockstep check, scaffold file set and gate are the same, and the verify job's Go leg follows go.mod rather than the kind. A reader choosing between them now knows the choice changes nothing the release does. The page says it rather than the intent's Decisions, which records the planning interview's rulings alone. Refs: iss-2609260149248713 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/04-launch.md | 4 +++- commands/launch.md | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 299a46055..72e877701 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -25,7 +25,9 @@ workflow that tags it. managed repository says once what it ships, in `.abcd/config/artefact.json`: `kind` is `plugin`, `binary` or `application`, `lockstep` names the JSON files a non-plugin kind holds in lockstep with its version-location primary, and `site` -reserves the release-rendered site's opt-in, read and not yet acted on. One +reserves the release-rendered site's opt-in, read and not yet acted on. +`binary` and `application` behave identically in every verb; the verify job's Go +leg keys on `go.mod`, never on the kind. One reader in `internal/core/launch` validates the file for every launch verb and for `ahoy`, whose `artefact.missing` gap writes it — kind `plugin` without a question for a repository carrying a plugin manifest, otherwise the kind the operator diff --git a/commands/launch.md b/commands/launch.md index 6c38231aa..6c0a7282d 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -41,7 +41,11 @@ Every verb runs against what the repository says it ships, declared once in gates already judge), `binary` (a built program, a Go binary in the first cut) or `application` (an application with its own build and publish steps). Any other value is refused by every verb, naming the kind and the accepted set, - before anything is written. + before anything is written. `binary` and `application` behave identically in + every verb: the same preview, lockstep check, scaffold file set and release + gate. Neither names a toolchain; the verify job's Go leg follows `go.mod`, not + the kind. The two names record what the repository ships, and choosing one + over the other changes nothing the release does. - `lockstep` — for a kind other than `plugin`, the JSON files held in lockstep with the version-location primary: each carries the version at its own `json_pointer`, or at the primary's when it names none. A plugin's lockstep is From de979ab57dc7b1fdbaadb5d21e3a247228ca7a8a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:55:16 +0100 Subject: [PATCH 087/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149248713?= =?UTF-8?q?=20=E2=80=94=20the=20launch=20page=20says=20the=20two=20kinds?= =?UTF-8?q?=20are=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260149248713 Assisted-by: Claude:claude-opus-5-5 --- ...act-kinds-binary-and-application-behave-identically.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md (63%) diff --git a/.abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md b/.abcd/work/issues/resolved/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md similarity index 63% rename from .abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md rename to .abcd/work/issues/resolved/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md index 2451edaa1..0f73743c2 100644 --- a/.abcd/work/issues/open/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md +++ b/.abcd/work/issues/resolved/iss-2609260149248713-the-artefact-kinds-binary-and-application-behave-identically.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/core/launch/kind.go" +resolution: "The launch page and the brief's launch chapter say binary and application behave identically in every verb." +impact: internal +resolved_by: + commit: "8740a2d9" --- The artefact kinds binary and application behave identically in every launch verb (KindBinary is referenced nowhere outside the reader, and the Go leg keys on go.mod), and neither the launch page nor the intent's Decisions says so, so a reader expects the choice to change what the release does. + +## Grounds + +- pursued: a reader of commands/launch.md learns the two non-plugin kinds are interchangeable; a verb that branches on KindBinary versus KindApplication would make the sentence false and show it wrong From bc48aad846195a5eac6180671f78f9eccaf9d75c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:32:41 +0100 Subject: [PATCH 088/107] chore: capture review2-loop1's two notes A spec step that waits on another intent has no marker, and the settled-question marker is admitted mid-sentence. Refs: iss-2609260932372448, iss-2609260932374727 Assisted-by: Claude:claude-opus-5-5 --- ...spec-step-waiting-on-an-intent-has-no-marker.md | 14 ++++++++++++++ ...2374727-settled-marker-admitted-mid-sentence.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609260932372448-spec-step-waiting-on-an-intent-has-no-marker.md create mode 100644 .abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md diff --git a/.abcd/work/issues/open/iss-2609260932372448-spec-step-waiting-on-an-intent-has-no-marker.md b/.abcd/work/issues/open/iss-2609260932372448-spec-step-waiting-on-an-intent-has-no-marker.md new file mode 100644 index 000000000..43acdcda7 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260932372448-spec-step-waiting-on-an-intent-has-no-marker.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260932372448" +slug: "spec-step-waiting-on-an-intent-has-no-marker" +severity: "minor" +category: "process" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-loop1 item 4" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/specs/open/spc-2609202134338445-one-verb-takes-a-single-intent-from-ready-to-delivered-witho.md" +--- + +A spec step that waits on another intent has no marker, so the build loop opens a lane on it: spc-2609202134338445's step 3 (the process driver) waits on itd-2609201916056194, and `build` takes the first unlanded step and briefs a lane there, which can only report the dependency. A blocked:/after: marker would change the first-unlanded-step rule and the ready row's count, which is a product design point, not an implementer's; routed to the product thinker. diff --git a/.abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md b/.abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md new file mode 100644 index 000000000..f570b8259 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260932374727" +slug: "settled-marker-admitted-mid-sentence" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-loop1 item 3" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/questions.go" +--- + +settledMarkRe (internal/core/intent/questions.go:30) admits `resolved:` or `deferred:` anywhere in an open-question item, so an item such as 'Which id wins once the split is resolved: the old or the new?' reads as settled and build starts past a real question. No record trips it today (every intent scanned); the tightening is a label at the item's start or after a closing bold plus dash, not mid-sentence. From 9e0881750e504b34d9f07a73d2c5943eda891b71 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:33:59 +0100 Subject: [PATCH 089/107] chore: capture the archive listing's index-read attributes Refs: iss-2609260933592838 Assisted-by: Claude:claude-opus-5-5 --- ...archive-tree-reads-attributes-from-the-index.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md diff --git a/.abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md b/.abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md new file mode 100644 index 000000000..7a95ce98a --- /dev/null +++ b/.abcd/work/issues/open/iss-2609260933592838-archive-tree-reads-attributes-from-the-index.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609260933592838" +slug: "archive-tree-reads-attributes-from-the-index" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review2-launchkind side note" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/gitutil/archive.go" +--- + +gitutil.ArchiveTree reads export attributes with `git check-attr --cached` (the index) while `git archive` reads them from the archived tree, so the two disagree when a .gitattributes change is staged but not committed: the launch listing can include or omit a path the released archive does the opposite with. `check-attr --source=<rev>` (git 2.40 or later) reads the same tree git archive does. From f52649aef74c635e66d165cba3959f7d5131a801 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:47:04 +0100 Subject: [PATCH 090/107] docs(brief): the sources chapter takes slot 33 Lab keeps brief chapter slot 31 and scribe takes the next free one when their integrations land, so the sources chapter moves from 31-source.md to 33-source.md. The chapter README row, every link to it and the release-gate manifest's pinned brief-doc list follow; checkerCount and promptHash are unchanged, since neither depends on the file's name. The slot is re-checked when this branch re-merges main after those lanes. Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/13-consult.md | 10 +++++----- .abcd/development/brief/04-surfaces/14-ingest.md | 4 ++-- .abcd/development/brief/04-surfaces/20-banlist.md | 2 +- .../brief/04-surfaces/{31-source.md => 33-source.md} | 0 .abcd/development/brief/04-surfaces/README.md | 2 +- .abcd/development/release-gate/manifest.json | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) rename .abcd/development/brief/04-surfaces/{31-source.md => 33-source.md} (100%) diff --git a/.abcd/development/brief/04-surfaces/13-consult.md b/.abcd/development/brief/04-surfaces/13-consult.md index db57f4c00..8833a4737 100644 --- a/.abcd/development/brief/04-surfaces/13-consult.md +++ b/.abcd/development/brief/04-surfaces/13-consult.md @@ -16,7 +16,7 @@ It is **host-delegated**: the workflow runs in the host agent from `abcd consult` verb and no bare-status render of its own. Reading the corpus is plain search with `grep` and file reads; every write — a ledger line, the banlist sync, the pre-share scan — goes through the `abcd source` verbs -([`31-source.md`](31-source.md)). +([`33-source.md`](33-source.md)). ## Sub-verbs @@ -84,7 +84,7 @@ The rule is backed mechanically rather than trusted alone, by the `abcd source` verbs. `sync-banlist` maintains a generated block in the repo's untracked `.abcd/.work.local/private-names.txt`, which the repo's committed pre-commit guard refreshes on every commit, where that store already exists and the guard can run -the verb (see [`31-source.md`](31-source.md)), and then enforces. `cite-check` scans a document before +the verb (see [`33-source.md`](33-source.md)), and then enforces. `cite-check` scans a document before it is shared and exits non-zero when a confidential identifier is present, naming only the key, so the report itself is safe to relay. Both read the same projection of the corpus through the same matcher as the guard, so a scan and a @@ -102,7 +102,7 @@ absence of complaint. the banlist verb's private layer (itd-74 / spc-20). The sources sync owns one fenced block in it and rewrites only that block, so hand-added and verb-added lines outside the fence survive every sync. See [`20-banlist.md`](20-banlist.md) -for the store itself and [`31-source.md`](31-source.md) for the projection. +for the store itself and [`33-source.md`](33-source.md) for the projection. **The mechanical layer is narrower than the hard rule, deliberately.** Patterns are derived from every confidential entry's title and aliases always, and from @@ -139,13 +139,13 @@ names. `/abcd:consult` is the read-and-record side of the sources system; [`/abcd:ingest`](14-ingest.md) is the write side that adds a source; and -[`/abcd:source`](31-source.md) is the binary both call. The three share the +[`/abcd:source`](33-source.md) is the binary both call. The three share the corpus and its ledger. ## References - Plugin command: [`commands/consult.md`](../../../../commands/consult.md) -- The verbs behind every write, and the store's schema: [`31-source.md`](31-source.md) +- The verbs behind every write, and the store's schema: [`33-source.md`](33-source.md) - Write side of the same corpus: [`14-ingest.md`](14-ingest.md) - The banlist store the guard wiring shares: [`20-banlist.md`](20-banlist.md) - The trust boundary the hard rule restates: diff --git a/.abcd/development/brief/04-surfaces/14-ingest.md b/.abcd/development/brief/04-surfaces/14-ingest.md index b34b84241..ca9389a6f 100644 --- a/.abcd/development/brief/04-surfaces/14-ingest.md +++ b/.abcd/development/brief/04-surfaces/14-ingest.md @@ -13,7 +13,7 @@ the read side and the provenance recorder. It is a **host-delegated command**: a markdown workflow that runs in the host agent. There is no top-level `abcd ingest` verb, no bare-status render, and no CLI flags of its own. The write it ends in is the source verb's add -([`31-source.md`](31-source.md)), which stores and classifies what the host hands +([`33-source.md`](33-source.md)), which stores and classifies what the host hands it and fetches and converts nothing. @@ -111,7 +111,7 @@ contract. - Plugin command: [`commands/ingest.md`](../../../../commands/ingest.md) - Read side of the same corpus: [`13-consult.md`](13-consult.md) -- The verb behind the write, and the corpus contract: [`31-source.md`](31-source.md) +- The verb behind the write, and the corpus contract: [`33-source.md`](33-source.md) <!-- surface-appendix:begin — generated from the command tree by `go generate ./internal/surface/cli`; never edit by hand --> diff --git a/.abcd/development/brief/04-surfaces/20-banlist.md b/.abcd/development/brief/04-surfaces/20-banlist.md index ad51a8a34..51e4d17f3 100644 --- a/.abcd/development/brief/04-surfaces/20-banlist.md +++ b/.abcd/development/brief/04-surfaces/20-banlist.md @@ -114,7 +114,7 @@ maintains them inside a fenced generated block in the same file, refusing a lega store that carries entries and leaving every line outside its block untouched. So a hand-added private entry and the corpus sync write into one store without either clobbering the other, and a hand-written key that collides with one the sync owns -is refused rather than overwritten. See [`31-source.md`](31-source.md) for the +is refused rather than overwritten. See [`33-source.md`](33-source.md) for the corpus side of that contract. Leading and trailing ASCII spaces and tabs are stripped, and so are a trailing diff --git a/.abcd/development/brief/04-surfaces/31-source.md b/.abcd/development/brief/04-surfaces/33-source.md similarity index 100% rename from .abcd/development/brief/04-surfaces/31-source.md rename to .abcd/development/brief/04-surfaces/33-source.md diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 7f8c0ce2e..2f34e81ff 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -43,7 +43,7 @@ are wiring rather than user-facing surface are listed separately under | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | | 29 | `/abcd:report` | shipped | Tell abcd about a defect or propose an enhancement from a repository it manages, into an inbox in your own account | [`29-report.md`](29-report.md) | | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | -| 31 | `/abcd:source` | shipped | Keep the documents you consult in a local corpus, record what each one changed, and ban the confidential ones' names at commit time | [`31-source.md`](31-source.md) | +| 33 | `/abcd:source` | shipped | Keep the documents you consult in a local corpus, record what each one changed, and ban the confidential ones' names at commit time | [`33-source.md`](33-source.md) | ## How much of this table a machine keeps honest diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index aba504c0a..c851e8385 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -46,7 +46,7 @@ ".abcd/development/brief/04-surfaces/27-implement.md", ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/30-inbox.md", - ".abcd/development/brief/04-surfaces/31-source.md", + ".abcd/development/brief/04-surfaces/33-source.md", ".abcd/development/brief/04-surfaces/README.md", ".abcd/development/brief/02-constraints/04-naming.md", ".abcd/development/brief/05-internals/01-agents.md", From da71e610a20586462278ece83da6324c4719d6c6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:52:21 +0100 Subject: [PATCH 091/107] docs(brief): the build chapter takes slot 34 Lab keeps brief chapter slot 31, scribe takes the next free one and the sources chapter holds 33, so the build chapter moves from 31-build.md to 34-build.md. The chapter README row, the implement chapter's two links and the release-gate manifest's pinned brief-doc list follow; checkerCount and promptHash are unchanged. The slot is re-checked when this branch re-merges main after those lanes. Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/27-implement.md | 4 ++-- .../brief/04-surfaces/{31-build.md => 34-build.md} | 0 .abcd/development/brief/04-surfaces/README.md | 2 +- .abcd/development/release-gate/manifest.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename .abcd/development/brief/04-surfaces/{31-build.md => 34-build.md} (100%) diff --git a/.abcd/development/brief/04-surfaces/27-implement.md b/.abcd/development/brief/04-surfaces/27-implement.md index 69bc269a9..d707b6318 100644 --- a/.abcd/development/brief/04-surfaces/27-implement.md +++ b/.abcd/development/brief/04-surfaces/27-implement.md @@ -10,7 +10,7 @@ It is also the family the implement loop is driven through (itd-2609201916151817 decision 8): `build` is what a person types, and the loop's status, step and receipt are sub-verbs of this verb, which a driving session calls. The loop's own state lives in the checkout's local tier, not in the shared run state below; -[`31-build.md`](31-build.md) is its chapter. The pacing intent +[`34-build.md`](34-build.md) is its chapter. The pacing intent (itd-2609201925079472) reads the loop's window clock. ## Sub-verbs @@ -205,7 +205,7 @@ signals anything. ## The implement loop Three sub-verbs drive the loop a build starts, each over the run's state file in -the checkout's local tier ([`31-build.md`](31-build.md) states the file, the +the checkout's local tier ([`34-build.md`](34-build.md) states the file, the checks and the step interface). The status render reads every run, or the one named, and writes nothing. The step performs the current lane's next step and exits; at a step that hands work to an agent it names the agent, the brief and diff --git a/.abcd/development/brief/04-surfaces/31-build.md b/.abcd/development/brief/04-surfaces/34-build.md similarity index 100% rename from .abcd/development/brief/04-surfaces/31-build.md rename to .abcd/development/brief/04-surfaces/34-build.md diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index de757a6b7..2dca9e9fa 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -43,8 +43,8 @@ are wiring rather than user-facing surface are listed separately under | 28 | `/abcd:peers` | shipped | See what the sibling worktrees and local branches hold before capturing, fixing or filing anything | [`08-abcd.md`](08-abcd.md) | | 29 | `/abcd:report` | shipped | Tell abcd about a defect or propose an enhancement from a repository it manages, into an inbox in your own account | [`29-report.md`](29-report.md) | | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | -| 31 | `/abcd:build` | shipped | Start the loop that takes one READY intent to delivered, refusing while a decision is open or a peer holds it | [`31-build.md`](31-build.md) | | 33 | `/abcd:source` | shipped | Keep the documents you consult in a local corpus, record what each one changed, and ban the confidential ones' names at commit time | [`33-source.md`](33-source.md) | +| 34 | `/abcd:build` | shipped | Start the loop that takes one READY intent to delivered, refusing while a decision is open or a peer holds it | [`34-build.md`](34-build.md) | ## How much of this table a machine keeps honest diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index ace3faa65..ada61b9c7 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -46,8 +46,8 @@ ".abcd/development/brief/04-surfaces/27-implement.md", ".abcd/development/brief/04-surfaces/29-report.md", ".abcd/development/brief/04-surfaces/30-inbox.md", - ".abcd/development/brief/04-surfaces/31-build.md", ".abcd/development/brief/04-surfaces/33-source.md", + ".abcd/development/brief/04-surfaces/34-build.md", ".abcd/development/brief/04-surfaces/README.md", ".abcd/development/brief/02-constraints/04-naming.md", ".abcd/development/brief/05-internals/01-agents.md", From bde878109bcf5a8c2ddecadcccd40bfa4132d69b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:20:02 +0100 Subject: [PATCH 092/107] fix(ahoy): a session store install could not create is a note stepHistory dropped three failures without a word: historyRoot's (no home directory to name), bootstrapHistory's (the store's index could not be created) and history.Resolve's (this repository's transcript store could not be created). Each is now a note naming the store and the reason, which is what the ahoy brief chapter promises of every install write. A failed index still lets the step go on to the repository's own store and registration, as it did, so each failure reports on its own. TestSessionStoreFailureIsNoted drives all three and was watched failing with no notes before the change. Refs: iss-2609261412505956 Assisted-by: Claude:claude-opus-5-5 --- ...s-the-session-store-s-failures-silently.md | 13 ++++++ internal/core/ahoy/apply.go | 20 +++++---- internal/core/ahoy/swallowed_writes_test.go | 44 +++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md diff --git a/.abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md b/.abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md new file mode 100644 index 000000000..96953e8c1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md @@ -0,0 +1,13 @@ +--- +schema_version: 1 +id: "iss-2609261412505956" +slug: "ahoy-install-drops-the-session-store-s-failures-silently" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +--- + +ahoy install drops the session store's failures silently: stepHistory (internal/core/ahoy/apply.go) ignores bootstrapHistory's error and historyRoot's, so a session store abcd could not create (a file where ~/.abcd belongs, or no HOME) leaves no note, against the ahoy brief chapter's rule that an install write it could not make is a note naming the file and the reason, never a silent omission (review2-ahoy item 1) diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 7fc872b93..0269ee87b 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -742,24 +742,28 @@ func (a *applyCtx) stepHistory() { if !a.approved[UserState] && !a.approved[SafeAutocreate] { return } - if a.approved[UserState] || a.approved[SafeAutocreate] { - if wrote, err := bootstrapHistory(); err == nil && wrote { - if root, e := historyRoot(); e == nil { - a.note(writeSessionStore, filepath.Join(root, "index.json")) - } - } - } + // Like every install write, a store it could not create is a note naming + // the store and the reason, never a silent omission. root, err := historyRoot() if err != nil { + a.refuse("could not set up this machine's session store: " + errText(err)) return } + if wrote, err := bootstrapHistory(); err != nil { + a.refuse("could not set up this machine's session store (" + displayPath(root) + "): " + errText(err)) + } else if wrote { + a.note(writeSessionStore, filepath.Join(root, "index.json")) + } sha := a.det.RepoIdentity.RootSHA if sha == "" { return } repoDir := filepath.Join(root, sha) store, storeErr := history.Resolve(a.cwd, sha) - if storeErr == nil && a.approved[SafeAutocreate] { + switch { + case storeErr != nil && a.approved[SafeAutocreate]: + a.refuse("could not set up this machine's session store for this repository: " + errText(storeErr)) + case storeErr == nil && a.approved[SafeAutocreate]: a.note(writeSessionStore, store.Records) } metaPath := filepath.Join(repoDir, "meta.json") diff --git a/internal/core/ahoy/swallowed_writes_test.go b/internal/core/ahoy/swallowed_writes_test.go index 22c804164..004e376ff 100644 --- a/internal/core/ahoy/swallowed_writes_test.go +++ b/internal/core/ahoy/swallowed_writes_test.go @@ -84,3 +84,47 @@ func TestMarkerBlockFailureIsNoted(t *testing.T) { } } } + +// TestSessionStoreFailureIsNoted is the history step's share of the rule the +// brief states for every install write: a session store abcd could not create +// is a note naming the store and the reason, never a silent omission. Both +// halves are driven — the store's directory refused (a file where ~/.abcd +// belongs) and a home directory the process cannot name at all. +func TestSessionStoreFailureIsNoted(t *testing.T) { + t.Run("the store cannot be created", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".abcd"), []byte("not a directory\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &applyCtx{cwd: t.TempDir(), approved: map[GapCategory]bool{SafeAutocreate: true}} + a.stepHistory() + if !notesCarryAll(a.notes, "session store", "not a directory") { + t.Errorf("no note says the session store was not created, and why; notes: %v", a.notes) + } + }) + t.Run("the transcript store cannot be created", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".abcd", "transcripts"), []byte("not a directory\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &applyCtx{cwd: t.TempDir(), approved: map[GapCategory]bool{SafeAutocreate: true}} + a.det.RepoIdentity.RootSHA = strings.Repeat("a", 40) + a.stepHistory() + if !notesCarryAll(a.notes, "session store", "transcripts", "not a real directory") { + t.Errorf("no note says the transcript store was not created, and why; notes: %v", a.notes) + } + }) + t.Run("the home directory is unknown", func(t *testing.T) { + t.Setenv("HOME", "") + a := &applyCtx{cwd: t.TempDir(), approved: map[GapCategory]bool{SafeAutocreate: true}} + a.stepHistory() + if !notesCarryAll(a.notes, "session store", "HOME") { + t.Errorf("no note says the session store was not created, and why; notes: %v", a.notes) + } + }) +} From 6e45aabfc209173b18056b20337dd948f83b00f1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:20:09 +0100 Subject: [PATCH 093/107] =?UTF-8?q?chore:=20resolve=20iss-2609261412505956?= =?UTF-8?q?=20=E2=80=94=20install=20notes=20a=20session=20store=20it=20cou?= =?UTF-8?q?ld=20not=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261412505956 Assisted-by: Claude:claude-opus-5-5 --- ...install-drops-the-session-store-s-failures-silently.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md (66%) diff --git a/.abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md b/.abcd/work/issues/resolved/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md similarity index 66% rename from .abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md rename to .abcd/work/issues/resolved/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md index 96953e8c1..55c5abcd9 100644 --- a/.abcd/work/issues/open/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md +++ b/.abcd/work/issues/resolved/iss-2609261412505956-ahoy-install-drops-the-session-store-s-failures-silently.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "stepHistory notes a failed home lookup, a failed index and a failed transcript store, each naming the store and the reason" +impact: fix +resolved_by: + commit: "bde87810" --- ahoy install drops the session store's failures silently: stepHistory (internal/core/ahoy/apply.go) ignores bootstrapHistory's error and historyRoot's, so a session store abcd could not create (a file where ~/.abcd belongs, or no HOME) leaves no note, against the ahoy brief chapter's rule that an install write it could not make is a note naming the file and the reason, never a silent omission (review2-ahoy item 1) + +## Grounds + +- pursued: an install whose session store cannot be created says so in its notes; shown wrong if TestSessionStoreFailureIsNoted's three cases ever pass with no note From fb08955079ac0bf0f5f2c9eba80a401bbf3e4c4d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:20:46 +0100 Subject: [PATCH 094/107] refactor(statusline): the settings guard is fsutil.CallersAlone ReadSettingsFile carried a third copy of the caller-alone test (no group or other write bit, owned by this session's uid) with its own owner-lookup seam. It now calls fsutil.CallersAlone, the one primitive ReadDeclaration and ahoy's data-directory check share, so the three cannot drift. The refusal wording is unchanged; the foreign-owner test swaps the lookup through fsutil.SwapOwnerUIDForTest, and was watched failing against the old copy (which that seam does not reach) before the fold. Owed by the ahoy lane's second review. Assisted-by: Claude:claude-opus-5-5 --- internal/core/statusline/settings.go | 18 +++++++----------- internal/core/statusline/settings_test.go | 6 +++--- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/internal/core/statusline/settings.go b/internal/core/statusline/settings.go index e4507620b..16ba4d35f 100644 --- a/internal/core/statusline/settings.go +++ b/internal/core/statusline/settings.go @@ -63,12 +63,6 @@ const SettingsDisplay = "~/" + SettingsRelPath // same cap the two sibling home-scoped declarations use. const maxSettingsBytes = 64 << 10 -// ownerUID is the package's view of fsutil.OwnerUID, held as a var for the -// reason rules/root.go holds its own: the foreign-owner branch cannot be -// provoked on a host where the test process can create only its own files, so -// substituting the lookup is the only way a detector can prove the refusal. -var ownerUID = fsutil.OwnerUID - // Pair is a badge's foreground and background, as hex. type Pair struct { Foreground string `json:"foreground"` @@ -323,13 +317,15 @@ func ReadSettingsFile(path string) (raw []byte, why string, err error) { if err != nil { return nil, "", nil } - switch { - case !fi.Mode().IsRegular(): + if !fi.Mode().IsRegular() { return nil, "it is not a regular file", nil - case fi.Mode().Perm()&0o022 != 0: - return nil, "it is writable by others, so its contents are not necessarily yours", nil } - if owner, err := ownerUID(path); err != nil || owner != uint32(os.Getuid()) { + // The one caller-alone test every home-scoped declaration applies + // (fsutil.CallersAlone), so the guard cannot drift from its siblings'. + switch err := fsutil.CallersAlone(path, fi); { + case errors.Is(err, fsutil.ErrDeclarationWritable): + return nil, "it is writable by others, so its contents are not necessarily yours", nil + case err != nil: return nil, "it is not owned by this session's uid", nil } raw, err = fsutil.ReadGuarded(path, maxSettingsBytes) diff --git a/internal/core/statusline/settings_test.go b/internal/core/statusline/settings_test.go index ee0410d7e..c43567b40 100644 --- a/internal/core/statusline/settings_test.go +++ b/internal/core/statusline/settings_test.go @@ -7,6 +7,8 @@ import ( "runtime" "strings" "testing" + + "github.com/intentdriven/abcd/internal/fsutil" ) // writeSettings lays a settings file at <home>/.abcd/statusline.json. @@ -367,9 +369,7 @@ func TestLoadRefusesAWorldWritableFile(t *testing.T) { func TestLoadRefusesAForeignOwner(t *testing.T) { home := t.TempDir() writeSettings(t, home, `{"schema_version":1,"disabled":true}`) - orig := ownerUID - t.Cleanup(func() { ownerUID = orig }) - ownerUID = func(string) (uint32, error) { return 4242, nil } + t.Cleanup(fsutil.SwapOwnerUIDForTest(func(string) (uint32, error) { return 4242, nil })) got, notes, err := LoadFrom(home) if err != nil { From c134c5bb06ff0c2577226bae71e25cc607ec8035 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:23:10 +0100 Subject: [PATCH 095/107] chore: note the exported-reach audit's build-tag and local-variable gaps The ahoy lane's second review found two more ways the parsed caller match counts a caller that is not one (build constraints ignored; a local variable named for the package under SkipObjectResolution). They are appended to the open record the audit's generalisation will close. Refs: iss-2609252211487887 Assisted-by: Claude:claude-opus-5-5 --- ...-exported-reach-caller-audit-iss-33-asked-for-exists-only.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md b/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md index 8fa336dc8..4b9b81db9 100644 --- a/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md +++ b/.abcd/work/issues/open/iss-2609252211487887-the-exported-reach-caller-audit-iss-33-asked-for-exists-only.md @@ -14,3 +14,5 @@ found_at: "internal/core/ahoy/exported_reach_test.go" The exported-reach caller audit iss-33 asked for exists only for internal/core/ahoy (TestEveryExportedAhoyFunctionHasAFrontDoor). A crude survey of the other internal/core packages (an exported top-level function with no 'pkg.Name' selector in non-test Go outside its package) lists about 140 names; many are reached only inside their own package, which is over-export rather than dead code, but some have no production caller anywhere, e.g. launch.Ship (grep for '.Ship(' outside tests finds none). Each hit needs sorting into dead scaffolding (delete or wire), in-package-only (unexport), or a declared test seam (name it ...ForTest), and the audit then generalised to every core package. Review 1 of the ahoy lane found the audit's caller match was a regex over raw source, so a comment or a string literal naming ahoy.X, or a .go file under testdata/, counted as a caller. The ahoy audit now parses each file (go/parser) and counts only a selector on the imported ahoy package, with testdata/ excluded; the generalisation this record asks for should reuse that matcher rather than the regex. One looseness remains by design: the ForTest suffix exempts a function as a declared test seam by its name alone, with nothing checking that production never calls it. + +Review 2 of the ahoy lane found two more loosenesses in the parsed matcher, both in the direction of counting a caller that is not one; neither reaches a real file today, and the generalisation should close both. Build constraints are ignored: parser.ParseFile reads a non-test file under `//go:build ignore` or an eval-only tag like any other, so a selector there counts as a front door although no production build compiles it. And the parse runs with SkipObjectResolution, so a local variable named `ahoy` in a file that also imports the package makes `ahoy.X` on the variable read as a call into the package. A dot import returns no selector and a blank import none either, so those two fail loud, the safe direction. From d0c1899262284daae9413703dbf4c203b992995f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:24:01 +0100 Subject: [PATCH 096/107] docs(prepare): the config ahoy install seeds is the repository's own The prepare-this-repo boundary said lint-config JSON is never committed, while `abcd ahoy install` seeds and commits .abcd/docs-lint.json. The record already settles which gives: the 2026-07-30 itd-74 decision has the install write that config create-if-absent, carrying the public banned-names family, and the product thinker's 2026-09-23 ruling records the house-style answer in it. The boundary, in the brief chapter and on the command page, now forbids lint config copied in by hand and says the config files the install seeds are the repository's own and committed, because the gates read them on every commit. Reproduced at this tip before the change (15-prepare-this-repo.md, the "Never commit downstream assets" item); a prose disagreement, found by the release-gate cross-check (x-044), with no automated detector to watch fail. Refs: iss-2609260552254288 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/15-prepare-this-repo.md | 8 ++++++-- commands/prepare-this-repo.md | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md index 75f4cca6b..2c35a007e 100644 --- a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md +++ b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md @@ -123,8 +123,12 @@ then ratified ADRs, then everything else read for understanding only. against hand-editing, and both keep the markers detection finds an adopted repository by (the product thinker's ruling of 2026-09-23). - **Never commit downstream assets.** Anything tooling will later provide - (persona data, lint-config JSON, content copied from the abcd record) is - applied, not copied. Only content about the target repository is committed. + (persona data, lint-config JSON copied in by hand, content copied from the + abcd record) is applied, not copied. Only content about the target repository + is committed. The config files `abcd ahoy install` seeds, `.abcd/docs-lint.json` + among them, are not downstream assets: each is the repository's own once it is + written (the docs-lint config carries its banned-names family and its + house-style answer), and the gates read it on every commit, so it is committed. - **Privacy.** A `private-names.txt`, if present, is read-only context for the audit and never reproduced in any committed or published artefact. - **No secret-pattern hooks.** No hook this command scaffolds carries a diff --git a/commands/prepare-this-repo.md b/commands/prepare-this-repo.md index 2fb850260..b3f466cfa 100644 --- a/commands/prepare-this-repo.md +++ b/commands/prepare-this-repo.md @@ -228,8 +228,11 @@ substance: ### Never commit downstream Assets that will later be provided by tooling are applied, not copied: no -`personas.json`, no lint-config JSON files, no content copied from the abcd -record. Commit only content that is about this repository. +`personas.json`, no lint-config JSON copied in by hand, no content copied from +the abcd record. Commit only content that is about this repository. The config +files `abcd ahoy install` seeds, `.abcd/docs-lint.json` among them, are this +repository's own once written, and the gates read them on every commit, so they +are committed. ## Definition of done From 54d720a11223f47bf3b3b42ef45bfb3f2762434f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:24:03 +0100 Subject: [PATCH 097/107] =?UTF-8?q?chore:=20resolve=20iss-2609260552254288?= =?UTF-8?q?=20=E2=80=94=20the=20prepare=20boundary=20agrees=20with=20ahoy?= =?UTF-8?q?=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260552254288 Assisted-by: Claude:claude-opus-5-5 --- ...-install-seeds-a-committed-abcd-docs-lint-json-from.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md (66%) diff --git a/.abcd/work/issues/open/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md b/.abcd/work/issues/resolved/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md similarity index 66% rename from .abcd/work/issues/open/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md rename to .abcd/work/issues/resolved/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md index 0528f4664..200538396 100644 --- a/.abcd/work/issues/open/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md +++ b/.abcd/work/issues/resolved/iss-2609260552254288-abcd-ahoy-install-seeds-a-committed-abcd-docs-lint-json-from.md @@ -9,6 +9,14 @@ found_during: "v0.11.0 release gate: brief-surface cross-check (autonomous run A origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/apply.go" +resolution: "the prepare-this-repo boundary now says the config ahoy install seeds is the repository's own and committed, matching the installer and the recorded decisions" +impact: fix +resolved_by: + commit: "d0c18992" --- `abcd ahoy install` seeds a committed .abcd/docs-lint.json from abcd's own writing-guide rules into a target repository when it has none (internal/core/ahoy/apply.go, defaults/docs-lint.json), while the prepare-this-repo chapter (15-prepare-this-repo.md:125-127) says lint-config JSON the tool supplies is applied and never committed: the installer and the criterion disagree and one of them has to give. Found by the v0.11.0 brief-surface cross-check (x-044). + +## Grounds + +- pursued: the brief and the command page agree with what ahoy install commits; shown wrong if the next release-gate cross-check reports the installer and the prepare boundary disagreeing again From 29c036675b9c93bdea7486e7acce65675bf0b9e4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:24:47 +0100 Subject: [PATCH 098/107] chore: recalibrate the reading windows at the integration tip Measured by a dry-run assembly on a clean clone of 54d720a1, the tip carrying the sources, loop1, launchkind and ahoy lanes; each window is the smallest ten-thousand boundary with at least one per cent headroom: widening 1,052,805 -> 1,070,000 (was 1,060,000, 0.68% headroom); entailment 354,326 -> 360,000 (was 350,000, exceeded); detection 1,061,841 -> 1,080,000 (was 1,060,000, exceeded). Comparative is unchanged. Refs: iss-2609251455354719 Assisted-by: Claude:claude-opus-5-5 --- .abcd/config/reading-presets.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 0d38242bc..934d66635 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1060000, - "measured_tokens_est": 1040187, - "measured_bytes": 4004721, - "measured_at": "68deae03f210499a62d356dc67101b268c15caed" + "tokens_est": 1070000, + "measured_tokens_est": 1052805, + "measured_bytes": 4053300, + "measured_at": "54d720a11223f47bf3b3b42ef45bfb3f2762434f" } }, "entailment": { @@ -132,10 +132,10 @@ "intent-projection" ], "window": { - "tokens_est": 350000, - "measured_tokens_est": 344124, - "measured_bytes": 1324879, - "measured_at": "68deae03f210499a62d356dc67101b268c15caed" + "tokens_est": 360000, + "measured_tokens_est": 354326, + "measured_bytes": 1364158, + "measured_at": "54d720a11223f47bf3b3b42ef45bfb3f2762434f" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1060000, - "measured_tokens_est": 1049223, - "measured_bytes": 4039509, - "measured_at": "68deae03f210499a62d356dc67101b268c15caed" + "tokens_est": 1080000, + "measured_tokens_est": 1061841, + "measured_bytes": 4088088, + "measured_at": "54d720a11223f47bf3b3b42ef45bfb3f2762434f" } } } From cb42621650ae61386272bc97101f64a87544b548 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:38:03 +0100 Subject: [PATCH 099/107] docs(brief): the prepare boundary names the install without its verb shape Surface-chapter prose states no command shape (the generated appendix holds it), and d0c18992 spelled the install's verb in the prepare-this-repo chapter; TestSurfaceChapterProseStatesNoShape refused it in the preflight. The sentence now says "the install". Refs: iss-2609260552254288 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/15-prepare-this-repo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md index 2c35a007e..589eefab2 100644 --- a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md +++ b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md @@ -125,7 +125,7 @@ then ratified ADRs, then everything else read for understanding only. - **Never commit downstream assets.** Anything tooling will later provide (persona data, lint-config JSON copied in by hand, content copied from the abcd record) is applied, not copied. Only content about the target repository - is committed. The config files `abcd ahoy install` seeds, `.abcd/docs-lint.json` + is committed. The config files the install seeds, `.abcd/docs-lint.json` among them, are not downstream assets: each is the repository's own once it is written (the docs-lint config carries its banned-names family and its house-style answer), and the gates read it on every commit, so it is committed. From fc14303ee48995f3f9f23392ca359bc5c4213e42 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:38:17 +0100 Subject: [PATCH 100/107] chore: re-measure the reading windows after the prepare wording fix The prepare-this-repo wording fix changed the corpus by eight bytes after 29c03667 measured it. A dry-run assembly on a clean clone of cb426216 gives widening 1,052,803, entailment 354,324 and detection 1,061,838 tokens; every declared window (1,070,000 / 360,000 / 1,080,000) keeps at least one per cent headroom, so only the measured fields move. Refs: iss-2609251455354719 Assisted-by: Claude:claude-opus-5-5 --- .abcd/config/reading-presets.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 934d66635..521ae6447 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -61,9 +61,9 @@ ], "window": { "tokens_est": 1070000, - "measured_tokens_est": 1052805, - "measured_bytes": 4053300, - "measured_at": "54d720a11223f47bf3b3b42ef45bfb3f2762434f" + "measured_tokens_est": 1052803, + "measured_bytes": 4053292, + "measured_at": "cb42621650ae61386272bc97101f64a87544b548" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 360000, - "measured_tokens_est": 354326, - "measured_bytes": 1364158, - "measured_at": "54d720a11223f47bf3b3b42ef45bfb3f2762434f" + "measured_tokens_est": 354324, + "measured_bytes": 1364150, + "measured_at": "cb42621650ae61386272bc97101f64a87544b548" } }, "comparative": { @@ -217,9 +217,9 @@ ], "window": { "tokens_est": 1080000, - "measured_tokens_est": 1061841, - "measured_bytes": 4088088, - "measured_at": "54d720a11223f47bf3b3b42ef45bfb3f2762434f" + "measured_tokens_est": 1061838, + "measured_bytes": 4088080, + "measured_at": "cb42621650ae61386272bc97101f64a87544b548" } } } From bd52f005cd5d57622c2905659c48c68c1d3d65f3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:28:10 +0100 Subject: [PATCH 101/107] fix(launch): the artefact declaration refuses repeated and case-folded keys ParseArtefact runs jsonstrict.NoDuplicateKeys before the decode, so {"kind": "application", "kind": "binary"} is refused rather than read as binary, at any nesting level. A lockstep object entry is read through a map with its keys matched exactly, so {"PATH": ...} and {"JSON_POINTER": ...} are refused as the top level already refuses "Kind". The launch page and the brief's launch chapter name the refusal. TestParseArtefactRefusesRepeatedAndCaseFoldedKeys was watched fail on a scratch copy (five of six cases accepted; the top-level case twin was already refused as an unknown key and stays as a pin). Refs: iss-2609260149249724 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/04-launch.md | 3 +- commands/launch.md | 4 ++ internal/core/launch/artefact.go | 40 +++++++++++++++++-- internal/core/launch/artefact_test.go | 26 ++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index 2972e0adf..eda2c0d55 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -31,7 +31,8 @@ leg keys on `go.mod`, never on the kind. One reader in `internal/core/launch` validates the file for every launch verb and for `ahoy`, whose `artefact.missing` gap writes it — kind `plugin` without a question for a repository carrying a plugin manifest, otherwise the kind the operator -answers. An unknown kind or a malformed declaration refuses every verb before +answers. An unknown kind or a malformed declaration — an unknown or repeated key, or a +lockstep entry key in another case, among them — refuses every verb before anything is written, naming the kind and the accepted set. The preview and the scaffold choose what to read and write by the kind, so they refuse a repository that has declared none, naming the file and the kinds and never a missing-file diff --git a/commands/launch.md b/commands/launch.md index 363b93b8d..7364ed9f5 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -58,6 +58,10 @@ Every verb runs against what the repository says it ships, declared once in - `site` — the release-rendered site opt-in, read and validated but not yet acted on. +The keys are read exactly as written: a key the declaration does not admit, a +key repeated at any level, and a lockstep entry key spelt in another case +(`PATH` for `path`) are each refused, before anything is written. + `ahoy install` writes the declaration: a repository carrying `.claude-plugin/plugin.json` adopts `kind: plugin` without being asked, and any other is asked its kind. The preview and the scaffold choose what to read and diff --git a/internal/core/launch/artefact.go b/internal/core/launch/artefact.go index 488f1bb9e..cf872b495 100644 --- a/internal/core/launch/artefact.go +++ b/internal/core/launch/artefact.go @@ -17,6 +17,7 @@ import ( "path/filepath" "strings" + "github.com/intentdriven/abcd/internal/core/jsonstrict" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -123,6 +124,12 @@ func LoadArtefact(repoRoot string) (Artefact, error) { // ParseArtefact validates a declaration's bytes. It is exported for the writer // in ahoy, which proves what it is about to write reads back. func ParseArtefact(data []byte) (Artefact, error) { + // A repeated key, at any level, is refused before the decode reads it + // last-wins: {"kind": "wasm", "kind": "binary"} names two kinds, and the + // reader must not pick one (iss-2609260149249724). + if err := jsonstrict.NoDuplicateKeys(data); err != nil { + return Artefact{}, preflight("the artefact declaration %s: %v", ArtefactRelPath, err) + } var raw map[string]json.RawMessage dec := json.NewDecoder(bytes.NewReader(data)) if err := dec.Decode(&raw); err != nil || raw == nil || dec.More() { @@ -179,11 +186,11 @@ func parseLockstep(v json.RawMessage) ([]LockstepFile, error) { for _, e := range entries { var f LockstepFile var p string - obj := json.NewDecoder(bytes.NewReader(e)) - obj.DisallowUnknownFields() if err := json.Unmarshal(e, &p); err == nil { f.Path = p - } else if err := obj.Decode(&f); err != nil { + } else if obj, ok := parseLockstepEntry(e); ok { + f = obj + } else { return nil, preflight("the artefact declaration %s: a lockstep entry is neither a path nor {\"path\", \"json_pointer\"}", ArtefactRelPath) } // The path is committed configuration data joined onto the repository @@ -207,6 +214,33 @@ func parseLockstep(v json.RawMessage) ([]LockstepFile, error) { return files, nil } +// parseLockstepEntry reads a lockstep object entry with its keys matched +// exactly. encoding/json binds a field name case-insensitively, so a struct +// decode would read {"PATH": ...} as a path while the top level refuses "Kind"; +// an entry is refused on any key but path and json_pointer as spelt. +func parseLockstepEntry(e json.RawMessage) (LockstepFile, bool) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(e, &raw); err != nil || raw == nil { + return LockstepFile{}, false + } + var f LockstepFile + for key, v := range raw { + var dst *string + switch key { + case "path": + dst = &f.Path + case "json_pointer": + dst = &f.Pointer + default: + return LockstepFile{}, false + } + if err := json.Unmarshal(v, dst); err != nil { + return LockstepFile{}, false + } + } + return f, true +} + // MarshalArtefact renders a declaration the way ahoy writes it: indented, with // an empty lockstep list and the site opt-in spelled out, so the file shows the // keys it admits. diff --git a/internal/core/launch/artefact_test.go b/internal/core/launch/artefact_test.go index 8ed86dc4d..0ac792979 100644 --- a/internal/core/launch/artefact_test.go +++ b/internal/core/launch/artefact_test.go @@ -104,3 +104,29 @@ func TestLoadArtefactRefusesMalformedDeclarations(t *testing.T) { }) } } + +// A declaration is read the way it is written: a repeated key is refused +// rather than read last-wins, and a lockstep entry's keys are matched exactly, +// so a case-folded spelling encoding/json would bind is refused as the top +// level already refuses "Kind" (iss-2609260149249724). +func TestParseArtefactRefusesRepeatedAndCaseFoldedKeys(t *testing.T) { + cases := map[string]struct{ body, want string }{ + "repeated kind": {`{"kind": "application", "kind": "binary"}`, `"kind"`}, + "kind case twin": {`{"kind": "binary", "KIND": "plugin"}`, `"KIND"`}, + "repeated entry path": {`{"kind": "binary", "lockstep": [{"path": "a.json", "path": "b.json"}]}`, `"path"`}, + "upper-case path": {`{"kind": "binary", "lockstep": [{"PATH": "a.json"}]}`, "lockstep entry"}, + "upper-case pointer": {`{"kind": "binary", "lockstep": [{"path": "a.json", "JSON_POINTER": "/v"}]}`, "lockstep entry"}, + "mixed-case entry key": {`{"kind": "binary", "lockstep": [{"Path": "a.json"}]}`, "lockstep entry"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + art, err := ParseArtefact([]byte(c.body)) + if err == nil { + t.Fatalf("accepted %s as %+v", c.body, art) + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("the refusal does not name %q: %v", c.want, err) + } + }) + } +} From 0d11fc69580d70cf15699294a813e28c0ea3566c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:28:35 +0100 Subject: [PATCH 102/107] =?UTF-8?q?chore:=20resolve=20iss-2609260149249724?= =?UTF-8?q?=20=E2=80=94=20the=20artefact=20reader=20refuses=20repeated=20k?= =?UTF-8?q?eys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lapsed v0.10.0 deferral fields are removed: jsonstrict is on main, and the reroute the deferral waited for is bd52f005c. Resolves: iss-2609260149249724 Assisted-by: Claude:claude-opus-5-5 --- ...t-kind-reader-decodes-with-plain-encoding-json-a.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) rename .abcd/work/issues/{open => resolved}/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md (58%) diff --git a/.abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md b/.abcd/work/issues/resolved/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md similarity index 58% rename from .abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md rename to .abcd/work/issues/resolved/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md index 70a37e5f8..dd52c33f9 100644 --- a/.abcd/work/issues/open/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md +++ b/.abcd/work/issues/resolved/iss-2609260149249724-the-artefact-kind-reader-decodes-with-plain-encoding-json-a.md @@ -9,8 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (fix round, review of lane la origin: researcher-authored production_mode: hand-written found_at: "internal/core/launch/artefact.go" -deferred_after: "v0.10.0" -deferral_reason: "deferred to the integration step (run A, 2026-09-26): the strict duplicate-key decoder jsonstrict lives on the unmerged lintB lane and copying it here would fork it; once lintB lands, the artefact-kind reader (top level and lockstep entries) reroutes its decode through jsonstrict, refusing duplicate keys and case-folded field names, and this record is resolved there" +resolution: "The artefact declaration refuses a repeated key at any level through jsonstrict, and reads a lockstep entry's keys exactly, so a case-folded spelling is refused." +impact: fix +resolved_by: + commit: "bd52f005c" --- The artefact-kind reader decodes with plain encoding/json: a duplicate top-level key takes the last value ({"kind":"wasm","kind":"binary"} reads as binary) and lockstep object entries decode case-insensitively ({"PATH":..,"JSON_POINTER":..} accepted) while the top level refuses Kind. + +## Grounds + +- pursued: a declaration with a repeated kind, a repeated entry path or an upper-case entry key is refused before any verb writes; TestParseArtefactRefusesRepeatedAndCaseFoldedKeys accepting any of them would show it wrong From 927d6ff35b68eb2610dc01bd53132f4bda8a255d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:31:55 +0100 Subject: [PATCH 103/107] docs(surface): the scaffold and build sentences say what the verbs check - launch scaffold: "Scaffold the release gate for the declared artefact kind: Writes its workflows and runbook; refuses an undeclared kind, or a hand-edited file without --confirm." (160 characters). The lane's meaning (the file set is shaped by the declared kind) is back, and the refusal scaffold.Scaffold makes through launch.LoadArtefact is named. - build: "refuses an open question" (the gate is open_questions), in the sentence, the command page's description and the brief's row 34. - site setup_test: the gate profile's workflow fires on workflow_call and on workflow_dispatch (the rehearsal), not "call-only"; comment only. commands.md and surface.json regenerated. From review-integ5 (two LOW-rank wording findings and two NITs). Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/README.md | 2 +- .abcd/development/release/surface.json | 4 ++-- commands/build.md | 2 +- docs/reference/cli/commands.md | 4 ++-- internal/core/site/setup_test.go | 6 ++++-- internal/core/surface/sentences.go | 6 +++--- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index 47249c1d0..419b4cc40 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -46,7 +46,7 @@ are wiring rather than user-facing surface are listed separately under | 31 | `/abcd:lab` | shipped | Run a lab against a pinned snapshot of the repository and harvest what it found, with the evidence kept out of the repository | [`31-lab.md`](31-lab.md) | | 32 | `/abcd:scribe` | shipped | Build the ledger scribe's context from the ledger alone, and ingest what it transcribed without letting it author anything | [`32-scribe.md`](32-scribe.md) | | 33 | `/abcd:source` | shipped | Keep the documents you consult in a local corpus, record what each one changed, and ban the confidential ones' names at commit time | [`33-source.md`](33-source.md) | -| 34 | `/abcd:build` | shipped | Start the loop that takes one READY intent to delivered, refusing while a decision is open or a peer holds it | [`34-build.md`](34-build.md) | +| 34 | `/abcd:build` | shipped | Start the loop that takes one READY intent to delivered, refusing while a question is open or a peer holds it | [`34-build.md`](34-build.md) | ## How much of this table a machine keeps honest diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 083c1cb29..d741a830e 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -341,7 +341,7 @@ "hidden": false, "group": "records", "block": "people", - "sentence": "Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open decision, a hold or a peer holding it.", + "sentence": "Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open question, a hold or a peer holding it.", "flags": [] }, { @@ -1970,7 +1970,7 @@ { "path": "abcd launch scaffold", "hidden": false, - "sentence": "Scaffold the changelog-driven release gate: Writes the release workflows and runbook; refuses to overwrite a hand-edited one without --confirm.", + "sentence": "Scaffold the release gate for the declared artefact kind: Writes its workflows and runbook; refuses an undeclared kind, or a hand-edited file without --confirm.", "flags": [ { "name": "confirm", diff --git a/commands/build.md b/commands/build.md index ddc42e8e9..f51e35a17 100644 --- a/commands/build.md +++ b/commands/build.md @@ -1,6 +1,6 @@ --- name: build -description: "Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open decision, a hold or a peer holding it." +description: "Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open question, a hold or a peer holding it." argument-hint: "<itd-N>" block: people --- diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index a2d2a9af4..99de4ef77 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -202,7 +202,7 @@ abcd banlist remove --private acme-internal ### `abcd build` -Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open decision, a hold or a peer holding it. +Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open question, a hold or a peer holding it. **Usage:** `abcd build <itd-N>` @@ -2022,7 +2022,7 @@ Run the release job's semantic-receipt gate locally, before the merge: Writes no #### `abcd launch scaffold` -Scaffold the changelog-driven release gate: Writes the release workflows and runbook; refuses to overwrite a hand-edited one without --confirm. +Scaffold the release gate for the declared artefact kind: Writes its workflows and runbook; refuses an undeclared kind, or a hand-edited file without --confirm. **Usage:** `abcd launch scaffold [--confirm] [flags]` diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go index fa309e0a6..9ebb88e3a 100644 --- a/internal/core/site/setup_test.go +++ b/internal/core/site/setup_test.go @@ -798,8 +798,10 @@ func TestTheWorkflowFiresOnEveryReleasePath(t *testing.T) { } // Each scaffold profile's workflows run under a name the trigger lists. The // plugin profile's release workflow is `release`; a gate profile's (a - // declared binary or application) is call-only, with no push trigger of its - // own, so its runs are reported under its caller's name, `auto-release`. + // declared binary or application) fires on workflow_call and on + // workflow_dispatch (the rehearsal, which publishes nothing), with no push + // trigger of its own, so its release runs are reported under its caller's + // name, `auto-release`. for _, p := range []struct { profile string subs scaffold.Substitutions diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index aa2c5ddd2..033bc2019 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -51,7 +51,7 @@ var sentences = map[string]string{ "Writes that layer's store; refuses a public entry curated by hand.", "abcd build": "Start the loop that takes one READY intent to delivered: " + - "Writes the run's state file in the local tier; refuses an open decision, a hold or a peer holding it.", + "Writes the run's state file in the local tier; refuses an open question, a hold or a peer holding it.", "abcd capture": "File an issue from quoted text, or render the ledger's status bare: " + "Writes one record under open/; refuses a lone word and any folder outside a checkout.", @@ -241,8 +241,8 @@ var sentences = map[string]string{ "Writes the archive into --out; refuses a dirty tree without --verify, and exits 1 when --verify finds it unpinned.", "abcd launch receipts": "Run the release job's semantic-receipt gate locally, before the merge: " + "Writes nothing; refuses with exit 1 when the release job would refuse the receipts.", - "abcd launch scaffold": "Scaffold the changelog-driven release gate: " + - "Writes the release workflows and runbook; refuses to overwrite a hand-edited one without --confirm.", + "abcd launch scaffold": "Scaffold the release gate for the declared artefact kind: " + + "Writes its workflows and runbook; refuses an undeclared kind, or a hand-edited file without --confirm.", "abcd launch ship": "Cut a release, deriving its version and records from what shipped: " + "Writes the CHANGELOG heading, RELEASE.md, and the archive pin; refuses a cut its gates stop.", From 9b5807f7912d5927426fdc16d229df851fc14eb3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:32:23 +0100 Subject: [PATCH 104/107] chore: capture the review's registerRepo silent-drop finding review-integ5 (LOW, pre-existing): registerRepo returns without a note when loadHistoryIndex fails, outside the three stepHistory failures iss-2609261412505956 made notes. Refs: iss-2609262032177818, iss-2609261412505956 Assisted-by: Claude:claude-opus-5-5 --- ...repo-returns-silently-when-the-session-store.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md diff --git a/.abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md b/.abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md new file mode 100644 index 000000000..40a59fbe6 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609262032177818" +slug: "ahoy-s-registerrepo-returns-silently-when-the-session-store" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-integ5" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/apply.go" +--- + +ahoy's registerRepo returns silently when the session store's index cannot be read: loadHistoryIndex's error (an unreadable, oversize or malformed ~/.abcd/history/index.json) skips the repository's registration with no note, unlike stepHistory's three store failures, which each leave a note naming the store and the reason From 5572760dee014e4ec24418a95a04846a4b2367cc Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:33:44 +0100 Subject: [PATCH 105/107] fix(ahoy): an unreadable session-store index is noted, not skipped silently registerRepo returned without a word when loadHistoryIndex failed, so an unreadable, oversize or malformed ~/.abcd/history/index.json left this repository unregistered with nothing on the receipt. It now leaves a note naming the index (home-redacted) and the reason, the shape of stepHistory's three store-failure notes. An absent index stays bootstrapHistory's to report. TestUnreadableHistoryIndexIsNoted was watched fail on a scratch copy (notes: []). Refs: iss-2609262032177818 Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/apply.go | 14 ++++++++++++- internal/core/ahoy/swallowed_writes_test.go | 23 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 0269ee87b..755570d5a 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -813,7 +813,19 @@ func (a *applyCtx) stepHistory() { // state is refused rather than silently applied. func (a *applyCtx) registerRepo(sha string) { idx, err := loadHistoryIndex() - if err != nil || idx == nil { + if err != nil { + // An index that exists but cannot be read (unreadable, oversize or + // malformed) skips the registration; like stepHistory's store failures, + // that is a note naming the index and the reason (iss-2609262032177818). + // An absent index is bootstrapHistory's to report, above. + where := "index.json" + if root, rerr := historyRoot(); rerr == nil { + where = displayPath(filepath.Join(root, "index.json")) + } + a.refuse("could not register this repository on this machine: the session store's index (" + where + ") could not be read: " + errText(err)) + return + } + if idx == nil { return } id := a.det.RepoIdentity diff --git a/internal/core/ahoy/swallowed_writes_test.go b/internal/core/ahoy/swallowed_writes_test.go index 004e376ff..12d79f204 100644 --- a/internal/core/ahoy/swallowed_writes_test.go +++ b/internal/core/ahoy/swallowed_writes_test.go @@ -128,3 +128,26 @@ func TestSessionStoreFailureIsNoted(t *testing.T) { } }) } + +// A session store whose index cannot be read skips this repository's +// registration, and the receipt says so rather than returning silently: the +// fourth of stepHistory's store failures (iss-2609262032177818). +func TestUnreadableHistoryIndexIsNoted(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".abcd", "history") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "index.json"), []byte("{not json\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &applyCtx{cwd: t.TempDir(), approved: map[GapCategory]bool{UserState: true}} + a.registerRepo(strings.Repeat("a", 40)) + if !notesCarryAll(a.notes, "register", "index.json") { + t.Errorf("no note says the repository was not registered because the index could not be read; notes: %v", a.notes) + } + if strings.Contains(strings.Join(a.notes, "\n"), home) { + t.Errorf("a note names the home directory unredacted: %v", a.notes) + } +} From 3bc0c2f208aff2acb91cd4383a62fe2bea58ebf7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:33:47 +0100 Subject: [PATCH 106/107] =?UTF-8?q?chore:=20resolve=20iss-2609262032177818?= =?UTF-8?q?=20=E2=80=94=20registerRepo=20notes=20an=20unreadable=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609262032177818 Assisted-by: Claude:claude-opus-5-5 --- ...egisterrepo-returns-silently-when-the-session-store.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md (61%) diff --git a/.abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md b/.abcd/work/issues/resolved/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md similarity index 61% rename from .abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md rename to .abcd/work/issues/resolved/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md index 40a59fbe6..188ce77a4 100644 --- a/.abcd/work/issues/open/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md +++ b/.abcd/work/issues/resolved/iss-2609262032177818-ahoy-s-registerrepo-returns-silently-when-the-session-store.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-integ5" origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/apply.go" +resolution: "registerRepo leaves a note naming the index and the reason when the session store's index cannot be read, as stepHistory does for its three store failures." +impact: fix +resolved_by: + commit: "5572760de" --- ahoy's registerRepo returns silently when the session store's index cannot be read: loadHistoryIndex's error (an unreadable, oversize or malformed ~/.abcd/history/index.json) skips the repository's registration with no note, unlike stepHistory's three store failures, which each leave a note naming the store and the reason + +## Grounds + +- pursued: an install over a malformed index.json carries a note that the repository was not registered and why; TestUnreadableHistoryIndexIsNoted finding no such note would show it wrong From 00958138b4ac5f64ea9fe78501f4ad603acd2b9d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:47:33 +0100 Subject: [PATCH 107/107] chore: recalibrate the reading windows at the re-merged integration tip Measured on a clean clone of 3bc0c2f2 with a dry-run assemble per position; window = ceil(tokens * 1.01 / 10000) * 10000, an existing window kept only with at least 1% headroom. - widening: 1260415 tokens / 4852600 bytes -> 1280000 (was 1270000, 0.76%) - entailment: 375787 / 1446780 -> 380000 kept (1.12% headroom) - detection: 1269451 / 4887388 -> 1290000 (was 1280000, 0.83%) Refs: iss-2609251455354719 Assisted-by: Claude:claude-opus-5-5 --- .abcd/config/reading-presets.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 10e65cbef..7729956ef 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1270000, - "measured_tokens_est": 1249424, - "measured_bytes": 4810285, - "measured_at": "f0746858220e04d862e00ff431dfec361756d52b" + "tokens_est": 1280000, + "measured_tokens_est": 1260415, + "measured_bytes": 4852600, + "measured_at": "3bc0c2f208aff2acb91cd4383a62fe2bea58ebf7" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 380000, - "measured_tokens_est": 367204, - "measured_bytes": 1413738, - "measured_at": "f0746858220e04d862e00ff431dfec361756d52b" + "measured_tokens_est": 375787, + "measured_bytes": 1446780, + "measured_at": "3bc0c2f208aff2acb91cd4383a62fe2bea58ebf7" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1280000, - "measured_tokens_est": 1258460, - "measured_bytes": 4845073, - "measured_at": "f0746858220e04d862e00ff431dfec361756d52b" + "tokens_est": 1290000, + "measured_tokens_est": 1269451, + "measured_bytes": 4887388, + "measured_at": "3bc0c2f208aff2acb91cd4383a62fe2bea58ebf7" } } }