From ef9ef3819c6401f4806b6c5353ce8ca0a29d818f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:27:34 +0100 Subject: [PATCH 01/78] fix(intent): a settled label counts only where a label is written The build's open-question check read `resolved:` or `deferred:` anywhere in an item, so "Which id wins once the split is resolved: the old or the new?" read as settled and build would start past a real question. The label now counts opening a line of the item, after a closing bold, or after a dash; the bold-span marker keeps its reach. Every intent in the tree reads the same open-question count under the tightened rule, so no record changes verdict. The brief's build chapter says so, and DECISIONS.md carries a dated correction to the 2026-09-25 entry. Refs: iss-2609260932374727 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/34-build.md | 10 +++-- .abcd/work/DECISIONS.md | 1 + internal/core/intent/questions.go | 45 ++++++++++++++----- internal/core/intent/questions_test.go | 11 +++++ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index b44c89b3c..c64d5c1b8 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -41,10 +41,12 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 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`). + `Deferred:`) opening a line of the item, after a closing bold or after a + dash — is not a question; the same word and colon mid-sentence are prose. + 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`, and its + 2026-09-28 correction). - **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 98d80d19e..63b7d9771 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2566,3 +2566,4 @@ together (the script's header says why there is no escape hatch). - 2026-09-26 — Three departures the scribe lane (itd-2609020625402599, spc-2609020626045177) made from its closed spec, which its review found recorded only in code, the chapter or the lane report, recorded here (implementer of lane fix2-scribe, autonomous run A). First, the surface chapter is `04-surfaces/31-scribe.md`, not the `24-scribe.md` the spec names: row 24 is `decide`'s, taken before the lane landed, so the chapter took the next free row. The spec is closed and keeps its text; four open lanes claim row 31 (build, lab, source ledger and this one), so the integration step renumbers three of them. Second, the transcript store's check is `SessionSeparation(repoRoot, rootSHA)`, not the `SessionSeparation(rootSHA)` the spec names, because it reads through `history.List`, which takes the repository root to find a checkout's opt-in per-repo transcript store; the report is unchanged. Third, the scribe's context is assembled from the ledger as it stands in the working tree, uncommitted records included, while the intent's scope condition (cond-2609020626046719) says committed ledger content. The working-tree read is what the code does today and the chapter says so. Whether the condition or the code should move is not decided here: it is captured as iss-2609261056373310, a ruling owed. - 2026-09-25 — itd-2609211913453478's acceptance criterion 4 ships under two readings the intent's scope line does not state. A glossary entry's `not_to_be_confused_with` passes when at least one member names a family row on the record-families page or the page itself, where the scope line says the field "may name only a family on the page"; the stricter reading would force nonsense pairs such as warm against intent, and the entries keep their real confusion pairs. The family-key rule (`record_family_key`, warn) reports a record frontmatter key only when the glossary already marks that word superseded or forbidden, so a brand-new grouping word with no row (the intent's own Mechanism case, e.g. an `initiative:` key) is not detected by construction, and the stores the page does not row (adr, rdi, dsp, rdg, adm, srp) are not reported. The six `grandfathered_at_phase` warnings on itd-20, 27, 28, 63, 69 and 72 are history and stay. Recorded for the product thinker to confirm or widen (autonomous run A, glossary lane review, orchestrator abcd-39). - 2026-09-26 — The lab store is keyed `~/.abcd/lab///`, with one `index.jsonl` registry per root-sha lane beside the lab homes (lane implementer, autonomous run A, on review-lab's third finding against spc-2609212141418943 for itd-2609212137128014). This supersedes two recorded texts: the spec's literal `~/.abcd/lab//` (scope item 1), and the 2026-08-31 lab-convention entry's hand-run keying `~/.abcd/lab/-/` with a single top-level `~/.abcd/lab/index.jsonl`, whose stated divergence from root-sha keying is withdrawn. Why: the intent's scope condition keys the store "as the other machine-scoped stores are", and the worktree and transcript stores key on the repository's root commit, because a checkout moves, is renamed and is cloned twice on one machine while its root commit does none of that; a lab's identity is still its intention, carried by its id `lab--` (the UTC mint time and the pin), so several labs share one baseline inside one lane. The hand-run labs that predate the verb stay where they are, beside the root-sha lanes, and the verb neither reads nor writes them or the top-level registry, so no real lab is moved or migrated by the change. A later text naming `~/.abcd/lab//` (the open spc-2609221011151661's `pairs.jsonl` among them) means the lab home inside its root-sha lane. +- 2026-09-28 — Correction to the 2026-09-25 entry on the build's open-question check (lane implementer, autonomous run A, lane drainInt, on iss-2609260932374727). A settled LABEL (`resolved:`, `RESOLVED:`, `Deferred:`) is no longer read anywhere in the item: it counts opening a line of the item (its first line or a continuation line), after a closing bold (`**Which surface scaffolds it?** RESOLVED:`), or after a dash (`**Refusal breadth** — resolved:`, `**Relationship to itd-73** (derived versioning) — RESOLVED:`). The same word and colon mid-sentence are prose, so "Which id wins once the split is resolved: the old or the new?" is a question, where the entry's "anywhere in the item" read it as settled and let build start past it. The bold-span marker keeps its reach anywhere in the item. Every intent in the tree reads the same open-question count under the tightened rule as under the old one, so no record changes verdict. diff --git a/internal/core/intent/questions.go b/internal/core/intent/questions.go index 461260e30..fd0826f95 100644 --- a/internal/core/intent/questions.go +++ b/internal/core/intent/questions.go @@ -24,11 +24,18 @@ var ( // 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 + // settledBoldRe is an item's explicit disposition as 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*:`) + // `**explicit deferral**`), wherever in the item the span sits. + settledBoldRe = regexp.MustCompile(`(?i)\*\*(resolved|deferred|explicitly deferred|explicit deferral)\b`) + // settledLabelRe is the disposition as a LABEL (`resolved:`, `RESOLVED:`, + // `Deferred:`), and a label only where a label is written: opening a line of + // the item, after a closing bold (`**Which surface?** RESOLVED:`), or after + // a dash (`**Refusal breadth** — resolved:`). Mid-sentence the same word + // and colon are prose — "once the split is resolved: the old or the new?" + // is a question — and reading them as a marker let build start past it + // (iss-2609260932374727). + settledLabelRe = regexp.MustCompile(`(?i)(^|\*\*[ \t]*|[—–][ \t]*|[ \t]-[ \t]+)(resolved|deferred)[ \t]*:`) ) // OpenQuestions returns the questions an intent's `## Open Questions` section @@ -46,9 +53,10 @@ var ( // 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. +// deferred**`, `**explicit deferral**`) anywhere in the item, continuation +// lines included, or the word as a label (`resolved:`, `Deferred:`) +// opening a line of the item, after a closing bold, or after a dash — is +// not a question. The same word and colon mid-sentence are prose. // // 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, @@ -62,7 +70,7 @@ func OpenQuestions(content string) []string { if item == nil { return } - if !settledItem(strings.Join(item, " ")) { + if !settledItem(item) { out = append(out, item[0]) } item = nil @@ -97,8 +105,21 @@ func OpenQuestions(content string) []string { 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) +// settledItem reports whether an item — its first line's text, then its +// continuation lines, each trimmed — carries an explicit resolved or deferred +// marker and is not led "Open". The lines are judged apart for the label, whose +// place is the start of a line, and joined for the bold span, which may wrap. +func settledItem(lines []string) bool { + if openLeadRe.MatchString(lines[0]) { + return false + } + if settledBoldRe.MatchString(strings.Join(lines, " ")) { + return true + } + for _, ln := range lines { + if settledLabelRe.MatchString(ln) { + return true + } + } + return false } diff --git a/internal/core/intent/questions_test.go b/internal/core/intent/questions_test.go index c57de3805..cd978de57 100644 --- a/internal/core/intent/questions_test.go +++ b/internal/core/intent/questions_test.go @@ -80,6 +80,17 @@ func TestOpenQuestionsReadsTheSettledConvention(t *testing.T) { {"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."}}, + {"a label mid-sentence is not a marker (iss-2609260932374727)", head + + "- Which id wins once the split is resolved: the old or the new?\n" + + "- Once the flag is deferred: who picks it up?\n" + + "- Which runner?\n It stays a question until it is resolved: see below.\n", + []string{"Which id wins once the split is resolved: the old or the new?", + "Once the flag is deferred: who picks it up?", "Which runner?"}}, + {"a label opening the item or a continuation line (iss-2609260932374727)", head + + "- Resolved: the local runner.\n" + + "- Which runner?\n Deferred: to the runner intent.\n", nil}, + {"a label after a closing bold and a parenthetical dash (itd-93)", head + + "- **Relationship to itd-73** (derived versioning) — RESOLVED: the CHANGELOG.\n", nil}, {"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 + From 9d5b5349c21232f05167e4c5b80a4645708c5786 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:27:48 +0100 Subject: [PATCH 02/78] =?UTF-8?q?chore:=20resolve=20iss-2609260932374727?= =?UTF-8?q?=20=E2=80=94=20a=20settled=20label=20is=20not=20read=20mid-sent?= =?UTF-8?q?ence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609260932374727 Assisted-by: Claude:claude-opus-5-5 --- ...09260932374727-settled-marker-admitted-mid-sentence.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609260932374727-settled-marker-admitted-mid-sentence.md (52%) diff --git a/.abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md b/.abcd/work/issues/resolved/iss-2609260932374727-settled-marker-admitted-mid-sentence.md similarity index 52% rename from .abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md rename to .abcd/work/issues/resolved/iss-2609260932374727-settled-marker-admitted-mid-sentence.md index f570b8259..3293bbaaa 100644 --- a/.abcd/work/issues/open/iss-2609260932374727-settled-marker-admitted-mid-sentence.md +++ b/.abcd/work/issues/resolved/iss-2609260932374727-settled-marker-admitted-mid-sentence.md @@ -9,6 +9,14 @@ 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" +resolution: "A settled label (resolved:, deferred:) counts only opening a line of an Open Questions item, after a closing bold, or after a dash; mid-sentence it is prose and the item stays a question. The bold-span marker keeps its reach. No committed intent changes verdict." +impact: fix +resolved_by: + commit: "ef9ef3819c6401f4806b6c5353ce8ca0a29d818f" --- 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. + +## Grounds + +- pursued: we expect anchoring the label to its written positions to stop a question that uses the word mid-sentence from reading as settled without refusing any record the tree settles; it is shown wrong if a record written in the settled convention starts reading as open, or a mid-sentence label still admits an item From fe9c705c2d9f239b5d8b5afee46877072e949fd0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:28:54 +0100 Subject: [PATCH 03/78] fix(intent): an early close's impact refusal names intent plan --impact Both refusals of --impact at a close that ships nothing (another spec still open, or a remainder minted) said only to supply the impact at the close that ships. They now also name `abcd intent plan --impact `, which stamps the judgement on the planned record at once, after which the close that ships needs no flag. The value is echoed only when shipped/ would accept it, so the refusal never hands back a command that is itself refused. The test walks the named route end to end: plan stamps, the remainder close and the shipping close both go through without the flag. Refs: iss-2609240646522330 Assisted-by: Claude:claude-opus-5-5 --- internal/core/intent/lifecycle.go | 26 ++++++++++++++++--- internal/core/intent/multispec_test.go | 35 ++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/internal/core/intent/lifecycle.go b/internal/core/intent/lifecycle.go index 63348bfd1..5ec2d0ac0 100644 --- a/internal/core/intent/lifecycle.go +++ b/internal/core/intent/lifecycle.go @@ -887,14 +887,20 @@ func Reconcile(repoRoot, specID, impact string, remainder RemainderRequest) (Rec // accepting it here would report a write that never happened — and stamping it // early would pre-decide the derived version of a release this close does not // reach. The judgement belongs at the close that ships (adr-2609151513118583). + // + // Both refusals name the way to keep a judgement already made: `intent plan + // --impact` stamps it on the planned record now, and the close that ships + // then needs no flag. Without that a lane that knew the impact dropped it, + // leaving it to be remembered at the last close (iss-2609240646522330). if strings.TrimSpace(impact) != "" { + keep := keepImpactNow(intentID, impact) if len(held) > 0 { - return ReconcileResult{}, fmt.Errorf("intent: --impact is the judgement %s carries into shipped/, and this close ships nothing — %s is still open on %s; re-run without --impact, and supply it at the close that ships", - intentID, strings.Join(specIDs(held), ", "), intentID) + return ReconcileResult{}, fmt.Errorf("intent: --impact is the judgement %s carries into shipped/, and this close ships nothing — %s is still open on %s; re-run without --impact, and %s", + intentID, strings.Join(specIDs(held), ", "), intentID, keep) } if remainder.Slug != "" { - return ReconcileResult{}, fmt.Errorf("intent: --impact is the judgement %s carries into shipped/, and a remainder spec leaves it planned; re-run without --impact, and supply it at the close that ships", - intentID) + return ReconcileResult{}, fmt.Errorf("intent: --impact is the judgement %s carries into shipped/, and a remainder spec leaves it planned; re-run without --impact, and %s", + intentID, keep) } } @@ -1249,6 +1255,18 @@ func recordedImpact(content string) string { return recorded } +// keepImpactNow is the remedy clause an early close's impact refusal ends on: +// the judgement is recorded now with `abcd intent plan`, or supplied at the +// close that ships. The value is echoed only when it is one shipped/ accepts, +// so a refusal never hands back a command that would itself be refused. +func keepImpactNow(intentID, impact string) string { + value := "<" + shipImpactValues + ">" + if validShipImpact(impact) == nil { + value = impact + } + return fmt.Sprintf("either record it now with `abcd intent plan %s --impact %s`, after which the close that ships needs no flag, or supply it at the close that ships", intentID, value) +} + // validShipImpact applies the shipped/ bar to one impact value: a legal member // of the changelog vocabulary, and not `internal`. It is the same pair of checks // CreateFromText makes at the seed, so a value either boundary accepts survives diff --git a/internal/core/intent/multispec_test.go b/internal/core/intent/multispec_test.go index 853587f5d..96e56f84b 100644 --- a/internal/core/intent/multispec_test.go +++ b/internal/core/intent/multispec_test.go @@ -70,6 +70,11 @@ func TestReconcileRefusesImpactWhenAnotherSpecStaysOpen(t *testing.T) { if !strings.Contains(err.Error(), "spc-2") || !strings.Contains(err.Error(), "still open") { t.Fatalf("refusal must name the open spec that keeps the intent planned: %v", err) } + // The judgement need not wait for the close that ships: the refusal names + // the verb that records it now (iss-2609240646522330). + if !strings.Contains(err.Error(), "abcd intent plan itd-10 --impact fix") { + t.Fatalf("refusal must name `abcd intent plan itd-10 --impact fix` as the way to record the impact now: %v", err) + } if _, err := os.Stat(filepath.Join(root, specsOpen, "spc-1-alpha.md")); err != nil { t.Fatalf("nothing may move on the refusal: %v", err) } @@ -142,14 +147,22 @@ func TestReconcileMintsTheRemainderSpec(t *testing.T) { // An --impact at a close that mints a remainder is refused: that close ships // nothing, so the judgement would be written against a record staying planned. +// The refusal names `abcd intent plan --impact `, which keeps +// the judgement now, and the close that ships then needs no flag +// (iss-2609240646522330). func TestReconcileRefusesImpactWithARemainder(t *testing.T) { root := t.TempDir() - writeFile(t, root, plannedDir+"/itd-10-alpha.md", plannedLinked("itd-10", "alpha", "spc-1")) + unjudged := strings.Replace(plannedLinked("itd-10", "alpha", "spc-1"), "impact: fix\n", "", 1) + writeFile(t, root, plannedDir+"/itd-10-alpha.md", unjudged) writeFile(t, root, specsOpen+"/spc-1-alpha.md", specNaming("spc-1", "alpha", "itd-10")) - if _, err := Reconcile(root, "spc-1", "fix", RemainderRequest{Slug: "the-rest"}); err == nil { + _, err := Reconcile(root, "spc-1", "fix", RemainderRequest{Slug: "the-rest"}) + if err == nil { t.Fatal("--impact with a remainder must be refused") } + if !strings.Contains(err.Error(), "abcd intent plan itd-10 --impact fix") { + t.Fatalf("refusal must name `abcd intent plan itd-10 --impact fix` as the way to record the impact now: %v", err) + } // Nothing was minted and nothing moved. entries, err := os.ReadDir(filepath.Join(root, specsOpen)) if err != nil { @@ -158,6 +171,24 @@ func TestReconcileRefusesImpactWithARemainder(t *testing.T) { if len(entries) != 1 { t.Fatalf("the refusal must mint nothing: %d specs in open/", len(entries)) } + + // The way the refusal names works: the plan stamps the impact on the + // planned record, the remainder close goes through without the flag, and + // the close that ships needs none. + if _, err := Plan(root, "itd-10", PlanOptions{Impact: "fix"}); err != nil { + t.Fatalf("intent plan --impact on the planned intent: %v", err) + } + res, err := Reconcile(root, "spc-1", "", RemainderRequest{Slug: "the-rest"}) + if err != nil { + t.Fatalf("the remainder close without --impact: %v", err) + } + last, err := Reconcile(root, res.Remainder.ID, "", RemainderRequest{}) + if err != nil { + t.Fatalf("the close that ships must need no --impact once plan recorded it: %v", err) + } + if !last.IntentMoved || last.To != BucketShipped { + t.Fatalf("the last close must ship the intent: %+v", last) + } } // TestPartialDeliveryResidualPassesRecordLint proves the state a partial close From dd8871d06624bfb848e6ba717615f263198f1672 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:28:57 +0100 Subject: [PATCH 04/78] =?UTF-8?q?chore:=20resolve=20iss-2609240646522330?= =?UTF-8?q?=20=E2=80=94=20the=20remainder=20refusal=20names=20intent=20pla?= =?UTF-8?q?n=20--impact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609240646522330 Assisted-by: Claude:claude-opus-5-5 --- ...30-remainder-close-refusal-omits-intent-plan-impact.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md (63%) diff --git a/.abcd/work/issues/open/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md b/.abcd/work/issues/resolved/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md similarity index 63% rename from .abcd/work/issues/open/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md rename to .abcd/work/issues/resolved/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md index 93411d25a..17598bffb 100644 --- a/.abcd/work/issues/open/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md +++ b/.abcd/work/issues/resolved/iss-2609240646522330-remainder-close-refusal-omits-intent-plan-impact.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/lifecycle.go" +resolution: "Both early-close impact refusals (another spec still open; a remainder minted) name abcd intent plan --impact as the way to record the judgement now, after which the close that ships needs no flag." +impact: fix +resolved_by: + commit: "fe9c705c2d9f239b5d8b5afee46877072e949fd0" --- `abcd spec close --remainder --impact ` is refused by design, because a remainder close ships nothing, and the refusal says to supply the impact at the close that ships. It does not say that the judgement can be kept now: `abcd intent plan --impact ` stamps the impact on an intent that is already planned, and the later close then needs no flag. In autonomous run A a lane that knew the impact at a remainder close dropped it, which left it to be remembered by whichever session makes the final close. Wanted: both remainder refusals in internal/core/intent/lifecycle.go name `abcd intent plan --impact ` as the way to record the judgement at once. + +## Grounds + +- pursued: we expect naming the plan route in the refusal to keep an impact a lane already knows from being dropped at a remainder close; it is shown wrong if a lane refused there still re-runs without recording the impact and the judgement is lost to the final close From d39f08a0117557f7223eb154136039cbe9b7ff57 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:40:03 +0100 Subject: [PATCH 05/78] fix(intent): the owed listing withholds a local-tier path a reason quotes The dead-letter reason is free text a host's payload supplied, and the reader cuts only OUR retention clause off it, so a reason quoting a clause of the same shape put a local-tier path into `intent audit --json`, which promises never to hand one out. Every token naming the local tier is now withheld from the reported reason, and the rest of the reason is kept. The property test forges such a reason instead of trusting the fixture's own. The review's second note: the corroboration paragraph of iss-2609100509537730 sat inside its Grounds section, between the promote's pursued entry and the resolve's near-identical one. It moves into the body, above the section; both entries stay, because the section is append-only and each records its own act. Refs: iss-2609252038344132 Refs: iss-2609100509537730 Assisted-by: Claude:claude-opus-5-5 --- ...-debt-nothing-lists-owed-fidelity-reviews.md | 8 ++++---- internal/core/intent/owed.go | 17 ++++++++++++++++- internal/core/intent/owed_test.go | 13 +++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.abcd/work/issues/resolved/iss-2609100509537730-a-debt-nothing-lists-owed-fidelity-reviews.md b/.abcd/work/issues/resolved/iss-2609100509537730-a-debt-nothing-lists-owed-fidelity-reviews.md index 22a2fd40e..76ea3ad32 100644 --- a/.abcd/work/issues/resolved/iss-2609100509537730-a-debt-nothing-lists-owed-fidelity-reviews.md +++ b/.abcd/work/issues/resolved/iss-2609100509537730-a-debt-nothing-lists-owed-fidelity-reviews.md @@ -30,10 +30,6 @@ This is a designed obligation that silently accumulates, which is a sharper fail Wanted: a row in the status render or in `abcd lint` that names the number of owed fidelity reviews and the intents they belong to, and a listing verb that enumerates them. Related and filed separately: two defects that make an owed review expensive to discharge once found — the request carries no provenance hashes that `ingest` nevertheless requires, and it asks the host for a delivered diff range it has no mechanism to supply. -## Grounds - -- pursued: we expect a count of owed fidelity reviews on the bare status surfaces to be enough to make the debt get paid, because the debt was invisible rather than resisted, and a session that was asked what was outstanding discharged three in one sitting; it is shown wrong if the count is rendered and the debt still accumulates, which would mean visibility was not the constraint - **Corroboration (2026-09-18, Gropius managed-repo session gropiusllm-56, relayed to abcd-17).** Second managed repository, at v0.9.0, after twelve fidelity audits in one day: `abcd spec close` prints "fidelity review OWED, receipt @@ -43,4 +39,8 @@ ask is a read-only listing, `abcd intent audit --owed`, so a session can find what is outstanding without enumerating shipped intents by hand. Same shape as the filing; the number this time was twelve, all paid, found by grep. +## Grounds + +- pursued: we expect a count of owed fidelity reviews on the bare status surfaces to be enough to make the debt get paid, because the debt was invisible rather than resisted, and a session that was asked what was outstanding discharged three in one sitting; it is shown wrong if the count is rendered and the debt still accumulates, which would mean visibility was not the constraint + - pursued: we expect a count of owed fidelity reviews on the bare status surfaces to be enough to make the debt get paid, because the debt was invisible rather than resisted; it is shown wrong if the count is rendered and the debt still accumulates, which would mean visibility was not the constraint diff --git a/internal/core/intent/owed.go b/internal/core/intent/owed.go index 10c8f76b2..c77b30aeb 100644 --- a/internal/core/intent/owed.go +++ b/internal/core/intent/owed.go @@ -2,6 +2,7 @@ package intent import ( "path/filepath" + "regexp" "strings" ) @@ -116,11 +117,25 @@ func ReviewOf(repoRoot string, it Intent) (ReviewEntry, error) { case ReviewOwed, ReviewNone: e.ReEmit = ReEmitCommand(it.ID) case ReviewDeadLetter: - e.Reason = deadLetterReason(content, e.ReceiptID) + e.Reason = withholdLocalTier(deadLetterReason(content, e.ReceiptID)) } return e, nil } +// localTierTokenRe is one whitespace-delimited token that names the local tier. +var localTierTokenRe = regexp.MustCompile(`\S*\.work\.local\S*`) + +// withholdLocalTier replaces every token of a dead-letter reason that names the +// local tier. The reader cuts OUR retention clause off the reason, but the +// reason itself is free text a host's payload supplied (an out-of-enum token +// quoted back), so it can carry a clause of the same shape, and the listing +// promises never to hand out a path into the gitignored tier whoever wrote it +// (iss-2609252038344132). Only the path is withheld; the rest of the reason is +// what the reader is for. +func withholdLocalTier(reason string) string { + return localTierTokenRe.ReplaceAllString(reason, "[local-tier path withheld]") +} + // deadLetterReason recovers the reason deadLetterBlock wrote on the line after // the marker: "Fidelity review DEAD_LETTER (receipt R): . Raw payload // retained at . ...". The reason is cut at the LAST retention clause, diff --git a/internal/core/intent/owed_test.go b/internal/core/intent/owed_test.go index 501859fb2..9aff6c319 100644 --- a/internal/core/intent/owed_test.go +++ b/internal/core/intent/owed_test.go @@ -111,9 +111,17 @@ func TestReviewsReadsEveryShippedMarker(t *testing.T) { // TestReviewsCarriesNoLocalTierPath: the dead-letter block names where its raw // payload is retained, under the gitignored local tier; the listing reports the // reason and never that path. +// +// The reason is free text a host's payload supplied, so the fixture forges one +// that quotes a retention clause of its own: the property is that no local-tier +// path reaches the listing whoever wrote it, not that the block the reader cuts +// happens to hold only ours (iss-2609252038344132). func TestReviewsCarriesNoLocalTierPath(t *testing.T) { root := t.TempDir() seedReviewStates(t, root) + const forged = "verdict quoted back: Raw payload retained at .abcd/.work.local/reviews/rcp-0000000000f7.deadletter.json (see it)" + writeFile(t, root, shippedDir+"/itd-17-forged.md", shippedWithNotes("itd-17", "forged", + deadLetterBlock("rcp-0000000000f7", forged, reviewsRelDir+"/rcp-0000000000f7.deadletter.json", nil, func(s string) string { return oneLine(s) }))) l, err := Reviews(root) if err != nil { t.Fatal(err) @@ -125,6 +133,11 @@ func TestReviewsCarriesNoLocalTierPath(t *testing.T) { if strings.Contains(string(b), ".work.local") || strings.Contains(string(b), "request.md") { t.Fatalf("listing carries a local-tier path:\n%s", b) } + // The rest of the reason is still reported: only the path is withheld. + got := reviewsByID(t, l)["itd-17"].Reason + if !strings.HasPrefix(got, "verdict quoted back: Raw payload retained at ") || !strings.HasSuffix(got, " (see it)") { + t.Fatalf("the forged reason must be reported with only its path withheld; got %q", got) + } } // TestReviewsNeverWrites: the reader reads; it never re-emits a request or From 858be80efc957eb118bae33b7ffa87b7245ce72e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:29:51 +0100 Subject: [PATCH 06/78] =?UTF-8?q?chore:=20resolve=20iss-2609252038344132?= =?UTF-8?q?=20=E2=80=94=20the=20owed=20listing=20withholds=20a=20quoted=20?= =?UTF-8?q?local-tier=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609252038344132 Assisted-by: Claude:claude-opus-5-5 --- ...-low-notes-from-the-owed-review-1-intent-audit-json.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.md (55%) diff --git a/.abcd/work/issues/open/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.md b/.abcd/work/issues/resolved/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.md similarity index 55% rename from .abcd/work/issues/open/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.md rename to .abcd/work/issues/resolved/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.md index eadb93b73..3404c2e05 100644 --- a/.abcd/work/issues/open/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.md +++ b/.abcd/work/issues/resolved/iss-2609252038344132-two-low-notes-from-the-owed-review-1-intent-audit-json.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/intent/owed.go" +resolution: "The owed listing withholds every token of a dead-letter reason that names the local tier, so a forged retention clause in the host-supplied reason never reaches intent audit --json; the property test forges one. The corroboration prose of iss-2609100509537730 moves out of its Grounds section, whose two pursued entries stay because the section is append-only." +impact: fix +resolved_by: + commit: "d39f08a0117557f7223eb154136039cbe9b7ff57" --- Two low notes from the owed review: (1) intent audit --json echoes a dead-letter reason's attacker-supplied text verbatim, so a quoted-back token shaped like 'Raw payload retained at .../.work.local/...' puts a local-tier-looking string in the output (the real retention path is cut correctly; internal/core/intent/owed.go:120-147), and the test's .work.local substring check proves the fixture, not the property; (2) the resolution of iss-2609100509537730 appends a near-duplicate pursued grounds bullet after its corroboration prose. + +## Grounds + +- pursued: we expect withholding local-tier tokens at the reader to keep the listing's no-local-path promise against any reason text; it is shown wrong if a reason spelled to name the local tier still reaches the JSON or text listing From c530b76645ebe2ed07bfe89c0cce02528e73c3f7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:30:28 +0100 Subject: [PATCH 07/78] test(intent): AddRelatedIssue's return contract is asserted at the primitive The finding named SetPromotedFrom returning a populated intent beside ErrBackEdgeTaken, with the primitive's own test discarding the return. The promote join under itd-4 AC3's names (48c61088c) replaced that primitive with AddRelatedIssue, which returns the zero Intent on every refusal and the record's list on success. Its tests asserted the list on the append path only; they now assert it on the idempotent no-op too, the return the promote route reads the kept edge from on a re-run, and assert the zero Intent beside every refusal. Each assertion was watched fail against a mutated copy. Refs: iss-2609021815563506 Assisted-by: Claude:claude-opus-5-5 --- internal/core/intent/intent_test.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/core/intent/intent_test.go b/internal/core/intent/intent_test.go index 32ae9b313..de5387995 100644 --- a/internal/core/intent/intent_test.go +++ b/internal/core/intent/intent_test.go @@ -3,6 +3,7 @@ package intent import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -723,11 +724,15 @@ func TestAddRelatedIssueWritesOnlyTheBackEdge(t *testing.T) { // An unknown intent and a source outside the two graduating families are // refused, and nothing is written. - if _, err := AddRelatedIssue(root, "itd-99", "rdi-17"); err == nil { + if got, err := AddRelatedIssue(root, "itd-99", "rdi-17"); err == nil { t.Error("AddRelatedIssue on an intent in no bucket must be refused") + } else if !reflect.DeepEqual(got, Intent{}) { + t.Errorf("a refused AddRelatedIssue must return the zero Intent, got %+v", got) } - if _, err := AddRelatedIssue(root, "itd-10", "adr-4"); err == nil { + if got, err := AddRelatedIssue(root, "itd-10", "adr-4"); err == nil { t.Error("AddRelatedIssue with a source outside ^(iss|rdi)-[0-9]+$ must be refused") + } else if !reflect.DeepEqual(got, Intent{}) { + t.Errorf("a refused AddRelatedIssue must return the zero Intent, got %+v", got) } } @@ -757,10 +762,16 @@ func TestAddRelatedIssueKeepsAnExistingEdgeAndIsIdempotentOnTheSame(t *testing.T t.Fatalf("the record must carry both edges, the first kept first:\n%s", after) } - // The SAME source is a no-op that leaves the record byte-identical. - if _, err := AddRelatedIssue(root, "itd-11", "rdi-17"); err != nil { + // The SAME source is a no-op that leaves the record byte-identical, and it + // still returns the record's list: the promote route reads the kept edge + // off this return on a re-run too (iss-2609021815563506). + same, err := AddRelatedIssue(root, "itd-11", "rdi-17") + if err != nil { t.Fatalf("AddRelatedIssue with the source already there must be a no-op: %v", err) } + if got := strings.Join(same.RelatedIssues, ","); same.ID != "itd-11" || got != "rdi-17,rdi-18" { + t.Fatalf("the no-op must return the record as it stands: id %q, RelatedIssues %q, want itd-11 and rdi-17,rdi-18", same.ID, got) + } again, err := os.ReadFile(filepath.Join(root, draftsDir, "itd-11-beta.md")) if err != nil { t.Fatal(err) @@ -778,10 +789,15 @@ func TestAddRelatedIssueRefusesARecordCarryingTheRetiredField(t *testing.T) { root := t.TempDir() body := "---\nid: itd-12\nslug: gamma\nspec_id: null\nkind: null\npromoted_from: iss-3\n---\n# gamma\n" writeFile(t, root, draftsDir+"/itd-12-gamma.md", body) - _, err := AddRelatedIssue(root, "itd-12", "iss-4") + got, err := AddRelatedIssue(root, "itd-12", "iss-4") if err == nil { t.Fatal("AddRelatedIssue on a record carrying promoted_from must be refused") } + // A refusal returns no record beside its error: a caller reading the + // return on an error path reads nothing (iss-2609021815563506). + if !reflect.DeepEqual(got, Intent{}) { + t.Errorf("a refused AddRelatedIssue must return the zero Intent, got %+v", got) + } if !strings.Contains(err.Error(), "capture migrate") { t.Errorf("the refusal must name the migration; got %v", err) } From 43b21e46364362083f6164dc116154e63493268b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:30:30 +0100 Subject: [PATCH 08/78] =?UTF-8?q?chore:=20resolve=20iss-2609021815563506?= =?UTF-8?q?=20=E2=80=94=20AddRelatedIssue's=20return=20is=20asserted=20dir?= =?UTF-8?q?ectly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609021815563506 Assisted-by: Claude:claude-opus-5-5 --- ...tpromotedfrom-returns-a-populated-intent-beside-a-n.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md (53%) diff --git a/.abcd/work/issues/open/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md b/.abcd/work/issues/resolved/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md similarity index 53% rename from .abcd/work/issues/open/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md rename to .abcd/work/issues/resolved/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md index 246e5748b..f161147f8 100644 --- a/.abcd/work/issues/open/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md +++ b/.abcd/work/issues/resolved/iss-2609021815563506-intent-setpromotedfrom-returns-a-populated-intent-beside-a-n.md @@ -9,6 +9,14 @@ found_during: "itd-2609020625400169 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/lifecycle.go" +resolution: "SetPromotedFrom and ErrBackEdgeTaken were replaced by AddRelatedIssue (48c61088c), which never returns a populated intent beside an error. Its return contract is now asserted at the primitive: the record's list on the append and the idempotent no-op paths, and the zero Intent beside every refusal." +impact: internal +resolved_by: + commit: "c530b76645ebe2ed07bfe89c0cce02528e73c3f7" --- intent.SetPromotedFrom returns a populated Intent beside a non-nil ErrBackEdgeTaken, deliberately and documented, and the promote route depends on that value to report the kept back-edge, but the primitive's own test discards the return, so the stated contract is asserted only indirectly through the capture result's BackEdgeKept field. The fidelity verdict for itd-2609020625400169 records this as its one missing item; a primitive-level assertion closes it. + +## Grounds + +- pursued: we expect primitive-level assertions on AddRelatedIssue's return to catch a regression the promote route's BackEdgeKept would otherwise hide; it is shown wrong if a mutation returning a populated intent beside an error, or an empty list on the no-op, passes the intent tests From 7d8d1b71ca70d93f676bc8aac94aabecf2b3dcf3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:36:33 +0100 Subject: [PATCH 09/78] fix(frontmatter): one walk finds the block, and every sibling reads past a BOM frontmatter.Fields trims a BOM ahead of the opening delimiter, but the sibling walks that promise parity with it did not: intent's writers refused a BOM-led record the reader loads ("no leading frontmatter block"), the changelog took its whole frontmatter for the body, and record-lint took its `---` for an issue's title. frontmatter.Close is the reader's own walk, exported: the index of the closing delimiter, the BOM trimmed at line 0 and nowhere else, every delimiter judged by IsDelimiter. Intent's three writers (setFrontmatterFields, removeFrontmatterField, frontmatterClose), the changelog's bodyStart, the record page's body reader and peers' title reader route through it. record-lint's body start and agent capability scope ask frontmatterOpen, which already trims; the disposition parser, the launch prose gate and the site's frontmatter stripper trim the BOM at line 0 as every reader does. A writer keeps the file's BOM: it edits keys, never the first bytes. Refs: iss-2608221126066379 Assisted-by: Claude:claude-opus-5-5 --- internal/core/changelog/bom_test.go | 14 ++++++ internal/core/changelog/source.go | 18 +++---- internal/core/frontmatter/close_test.go | 43 ++++++++++++++++ internal/core/frontmatter/frontmatter.go | 22 +++++++++ internal/core/intent/bom_test.go | 63 ++++++++++++++++++++++++ internal/core/intent/hold.go | 15 ++---- internal/core/intent/intent.go | 23 +++------ internal/core/intent/reclassify.go | 18 ++++--- internal/core/issueschema/bom_test.go | 14 ++++++ internal/core/issueschema/disposition.go | 7 ++- internal/core/launch/bom_test.go | 14 ++++++ internal/core/launch/gates.go | 5 +- internal/core/lint/agentcontract.go | 10 ++-- internal/core/lint/bom_title_test.go | 26 ++++++++++ internal/core/lint/schema.go | 5 +- internal/core/peers/bom_test.go | 12 +++++ internal/core/peers/read.go | 14 ++---- internal/core/record/bom_test.go | 21 ++++++++ internal/core/record/record.go | 12 ++--- internal/core/site/bom_test.go | 16 ++++++ internal/core/site/sections.go | 7 +++ 21 files changed, 308 insertions(+), 71 deletions(-) create mode 100644 internal/core/changelog/bom_test.go create mode 100644 internal/core/frontmatter/close_test.go create mode 100644 internal/core/intent/bom_test.go create mode 100644 internal/core/issueschema/bom_test.go create mode 100644 internal/core/launch/bom_test.go create mode 100644 internal/core/lint/bom_title_test.go create mode 100644 internal/core/peers/bom_test.go create mode 100644 internal/core/record/bom_test.go create mode 100644 internal/core/site/bom_test.go diff --git a/internal/core/changelog/bom_test.go b/internal/core/changelog/bom_test.go new file mode 100644 index 000000000..df697bb4d --- /dev/null +++ b/internal/core/changelog/bom_test.go @@ -0,0 +1,14 @@ +package changelog + +import "testing" + +// TestSummariseReadsPastABOM: the changelog's body is the body frontmatter.Fields +// leaves, so a BOM-led record's frontmatter never reaches the changelog as its +// summary (iss-2608221126066379). +func TestSummariseReadsPastABOM(t *testing.T) { + t.Parallel() + title, summary := summarise("\ufeff---\nid: iss-1\nslug: the-slug\n---\n\nThe body paragraph.\n", "iss-1") + if title != "the-slug" || summary != "The body paragraph." { + t.Fatalf("summarise = (%q, %q), want (\"the-slug\", \"The body paragraph.\")", title, summary) + } +} diff --git a/internal/core/changelog/source.go b/internal/core/changelog/source.go index 05ba9dace..dcbec20a5 100644 --- a/internal/core/changelog/source.go +++ b/internal/core/changelog/source.go @@ -87,19 +87,13 @@ func pressReleaseSection(blob string) string { } // bodyStart returns the index of the first line after the frontmatter block, or -// 0 when the document has none. The block is delimited by the first TWO `---` -// lines, exactly as internal/core/frontmatter reads it, so the two never -// disagree about where the body begins. +// 0 when the document has none. The block is frontmatter.Close's, the one walk +// frontmatter.Fields makes, so the two never disagree about where the body +// begins — a private copy skipped the BOM Fields trims and handed a BOM-led +// record's whole frontmatter to the changelog as its body +// (iss-2608221126066379). func bodyStart(lines []string) int { - if len(lines) == 0 || strings.TrimRight(lines[0], " \t\r") != "---" { - return 0 - } - for i := 1; i < len(lines); i++ { - if strings.TrimRight(lines[i], " \t\r") == "---" { - return i + 1 - } - } - return 0 + return frontmatter.Close(lines) + 1 } // firstHeading returns the text of the first level-one heading in body, or "". diff --git a/internal/core/frontmatter/close_test.go b/internal/core/frontmatter/close_test.go new file mode 100644 index 000000000..21ce93e43 --- /dev/null +++ b/internal/core/frontmatter/close_test.go @@ -0,0 +1,43 @@ +package frontmatter + +import ( + "strings" + "testing" +) + +// TestCloseFindsTheBlockFieldsReads: Close is the one answer to "where does the +// leading block close", on Fields' terms exactly — a BOM tolerated at line 0 and +// nowhere else, a delimiter by IsDelimiter, an unclosed block no block at all — +// so a sibling reader that asks it cannot disagree with the reader about where +// the body begins (iss-2608221126066379). +func TestCloseFindsTheBlockFieldsReads(t *testing.T) { + t.Parallel() + cases := []struct { + name string + doc string + want int + }{ + {"plain", "---\nid: a\n---\nbody\n", 2}, + {"BOM ahead of the opening delimiter", "\ufeff---\nid: a\n---\nbody\n", 2}, + {"trailing whitespace and CRLF on the delimiters", "--- \r\nid: a\r\n---\t\r\nbody\r\n", 2}, + {"no opening delimiter", "id: a\n---\nbody\n", -1}, + {"unclosed", "---\nid: a\nbody\n", -1}, + {"a mid-file ZWNBSP line is not a close", "---\nid: a\n\ufeff---\nb: c\n---\n", 4}, + {"an indented rule is not a close", "---\nid: a\n ---\n---\n", 3}, + {"empty", "", -1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lines := strings.Split(tc.doc, "\n") + got := Close(lines) + if got != tc.want { + t.Fatalf("Close = %d, want %d", got, tc.want) + } + // Agreement with Fields: a block Close finds is one Fields reads. + if fields := Fields(lines); (got >= 0) != (len(fields) > 0) { + t.Fatalf("Close = %d but Fields read %d field(s)", got, len(fields)) + } + }) + } +} diff --git a/internal/core/frontmatter/frontmatter.go b/internal/core/frontmatter/frontmatter.go index 13e41cbdc..cad6f9b3e 100644 --- a/internal/core/frontmatter/frontmatter.go +++ b/internal/core/frontmatter/frontmatter.go @@ -115,6 +115,28 @@ func Fields(lines []string) map[string]Field { return fields } +// Close returns the index in lines of the leading frontmatter block's closing +// delimiter, or -1 when there is no block: no opening delimiter on line 0, or +// nothing closing it. It reads the block exactly as Fields does — the BOM +// trimmed at line 0 and nowhere else, every delimiter judged by IsDelimiter — +// so a reader that needs the block's extent rather than its keys (a writer +// splicing a key in, a reader taking the body after it) asks here instead of +// re-deriving the walk. Private copies of this walk skipped the BOM Fields +// trims, so a BOM-led record the reader accepted was refused by intent's +// writers and had its whole frontmatter taken for body by the changelog +// (iss-2608221126066379). +func Close(lines []string) int { + if len(lines) == 0 || !IsDelimiter(TrimBOM(lines[0])) { + return -1 + } + for i := 1; i < len(lines); i++ { + if IsDelimiter(lines[i]) { + return i + } + } + return -1 +} + // StripComment removes a trailing YAML comment from the text that follows a // key's colon, returning the value alone. // diff --git a/internal/core/intent/bom_test.go b/internal/core/intent/bom_test.go new file mode 100644 index 000000000..d5aba60f3 --- /dev/null +++ b/internal/core/intent/bom_test.go @@ -0,0 +1,63 @@ +package intent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/frontmatter" +) + +// TestIntentWritersReadTheBlockTheReaderReads: a BOM ahead of the opening +// delimiter is one frontmatter.Fields reads past, so every intent writer must +// too. Its private walks refused such a record with "no leading frontmatter +// block" while Load read it fine (iss-2608221126066379). The BOM survives the +// write: a writer edits the keys, never the file's first bytes. +func TestIntentWritersReadTheBlockTheReaderReads(t *testing.T) { + t.Parallel() + const bom = "\ufeff" + doc := bom + "---\nid: itd-10\nslug: alpha\nheld: yes\n---\n# alpha\n" + + set, err := setFrontmatterFields(doc, map[string]string{"spec_id": "spc-1", "slug": "beta"}) + if err != nil { + t.Fatalf("setFrontmatterFields on a BOM-led record: %v", err) + } + if !strings.HasPrefix(set, bom+"---\n") { + t.Fatalf("the write must keep the file's BOM and opening delimiter:\n%q", set) + } + fields := frontmatter.Fields(strings.Split(set, "\n")) + if fields["spec_id"].Value != "spc-1" || fields["slug"].Value != "beta" { + t.Fatalf("the reader must see both writes inside the block: %+v\n%q", fields, set) + } + + removed, err := removeFrontmatterField(doc, "held") + if err != nil { + t.Fatalf("removeFrontmatterField on a BOM-led record: %v", err) + } + if _, ok := frontmatter.Fields(strings.Split(removed, "\n"))["held"]; ok || !strings.HasPrefix(removed, bom) { + t.Fatalf("the key must be gone and the BOM kept:\n%q", removed) + } + + if _, err := frontmatterClose(strings.Split(doc, "\n")); err != nil { + t.Fatalf("frontmatterClose on a BOM-led record: %v", err) + } +} + +// TestAddRelatedIssueWritesABOMLedRecord: the verb a user runs, end to end, on +// a record the reader loads. +func TestAddRelatedIssueWritesABOMLedRecord(t *testing.T) { + root := t.TempDir() + writeFile(t, root, draftsDir+"/itd-10-alpha.md", + "\ufeff---\nid: itd-10\nslug: alpha\nspec_id: null\nkind: null\n---\n# alpha\n") + if _, err := AddRelatedIssue(root, "itd-10", "iss-4"); err != nil { + t.Fatalf("AddRelatedIssue on a BOM-led record the reader loads: %v", err) + } + after, err := os.ReadFile(filepath.Join(root, draftsDir, "itd-10-alpha.md")) + if err != nil { + t.Fatal(err) + } + if got := frontmatter.Fields(strings.Split(string(after), "\n"))["related_issues"].Value; got != "[iss-4]" { + t.Fatalf("related_issues = %q, want [iss-4]:\n%q", got, after) + } +} diff --git a/internal/core/intent/hold.go b/internal/core/intent/hold.go index 8d0a931bc..ef9b561c1 100644 --- a/internal/core/intent/hold.go +++ b/internal/core/intent/hold.go @@ -315,18 +315,9 @@ func validateHoldReason(reason string) error { // frontmatter block is an error (fail closed rather than corrupt a file). func removeFrontmatterField(content, key string) (string, error) { lines := strings.Split(content, "\n") - if len(lines) == 0 || strings.TrimRight(lines[0], " \t\r") != "---" { - return "", fmt.Errorf("intent: file has no leading frontmatter block") - } - closing := -1 - for i := 1; i < len(lines); i++ { - if strings.TrimRight(lines[i], " \t\r") == "---" { - closing = i - break - } - } - if closing < 0 { - return "", fmt.Errorf("intent: frontmatter block is not closed") + closing, err := frontmatterClose(lines) + if err != nil { + return "", err } out := make([]string, 0, len(lines)) for i, line := range lines { diff --git a/internal/core/intent/intent.go b/internal/core/intent/intent.go index c9746ddf5..5fa1fd20b 100644 --- a/internal/core/intent/intent.go +++ b/internal/core/intent/intent.go @@ -159,22 +159,13 @@ func hasAcceptanceCriteria(content string) bool { // leading frontmatter block is an error (fail closed rather than corrupt a file). func setFrontmatterFields(content string, updates map[string]string) (string, error) { lines := strings.Split(content, "\n") - // Match frontmatter.Fields's delimiter tolerance exactly: a `---` line may - // carry trailing whitespace ("--- "). Trimming only "\r" here (stricter than - // the reader) makes the writer skip a delimiter the reader accepts and insert - // keys into the body instead of the frontmatter — corrupting the record. - if len(lines) == 0 || strings.TrimRight(lines[0], " \t\r") != "---" { - return "", fmt.Errorf("intent: file has no leading frontmatter block") - } - closing := -1 - for i := 1; i < len(lines); i++ { - if strings.TrimRight(lines[i], " \t\r") == "---" { - closing = i - break - } - } - if closing < 0 { - return "", fmt.Errorf("intent: frontmatter block is not closed") + // The block is frontmatter.Fields's block, found by the same walk: a writer + // stricter than the reader about a delimiter (a trailing space, a BOM ahead + // of the opening `---`) skips one the reader accepts and inserts keys into + // the body, or refuses a record the reader reads (iss-2608221126066379). + closing, err := frontmatterClose(lines) + if err != nil { + return "", err } remaining := make(map[string]string, len(updates)) diff --git a/internal/core/intent/reclassify.go b/internal/core/intent/reclassify.go index 5244a31de..38c213895 100644 --- a/internal/core/intent/reclassify.go +++ b/internal/core/intent/reclassify.go @@ -472,17 +472,21 @@ func historyEntry(date, from, to, reason string) string { } // frontmatterClose returns the index of the frontmatter block's closing -// delimiter in lines, with setFrontmatterFields's delimiter tolerance. +// delimiter in lines. It is this package's one form of frontmatter.Close, the +// walk frontmatter.Fields makes, so every intent writer agrees with the reader +// about where the block ends — a BOM ahead of the opening delimiter included, +// which a private walk here refused while the reader accepted the record +// (iss-2608221126066379). The two refusals stay apart, because they name +// different repairs. func frontmatterClose(lines []string) (int, error) { - if len(lines) == 0 || strings.TrimRight(lines[0], " \t\r") != "---" { + if len(lines) == 0 || !frontmatter.IsDelimiter(frontmatter.TrimBOM(lines[0])) { return 0, fmt.Errorf("intent: file has no leading frontmatter block") } - for i := 1; i < len(lines); i++ { - if strings.TrimRight(lines[i], " \t\r") == "---" { - return i, nil - } + closing := frontmatter.Close(lines) + if closing < 0 { + return 0, fmt.Errorf("intent: frontmatter block is not closed") } - return 0, fmt.Errorf("intent: frontmatter block is not closed") + return closing, nil } // frontmatterKeyLine returns the index of key's top-level line, or -1. diff --git a/internal/core/issueschema/bom_test.go b/internal/core/issueschema/bom_test.go new file mode 100644 index 000000000..36fc7fe65 --- /dev/null +++ b/internal/core/issueschema/bom_test.go @@ -0,0 +1,14 @@ +package issueschema + +import "testing" + +// TestParseDispositionReadsPastABOM: a BOM is the file's encoding mark, not +// preamble, so a BOM-led disposition reads as the record it is +// (iss-2608221126066379). +func TestParseDispositionReadsPastABOM(t *testing.T) { + t.Parallel() + rec := ParseDisposition("dsp-1", "\ufeff---\nstate: held\nexit_condition: x\n---\nbody\n") + if rec.State != "held" || !rec.WellFormed { + t.Fatalf("ParseDisposition = %+v, want state held and well-formed", rec) + } +} diff --git a/internal/core/issueschema/disposition.go b/internal/core/issueschema/disposition.go index 098da83ab..d27101230 100644 --- a/internal/core/issueschema/disposition.go +++ b/internal/core/issueschema/disposition.go @@ -21,6 +21,8 @@ package issueschema import ( "sort" "strings" + + "github.com/intentdriven/abcd/internal/core/frontmatter" ) // DispositionRecord is one disposition as the standing computation sees it: the @@ -70,7 +72,10 @@ func ParseDisposition(id, content string) DispositionRecord { // The block must OPEN on the first line. A comment, a blank line, or any // other preamble means the file is not the shape a record is written in, and // tolerating it is precisely where the two readers parted company. - if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + // A BOM is not preamble: it is the file's encoding mark, which the strict + // ledger parser and frontmatter.Fields both trim at line 0 and only there + // (iss-2608221126066379). + if len(lines) == 0 || strings.TrimSpace(frontmatter.TrimBOM(lines[0])) != "---" { return rec } closeAt := -1 diff --git a/internal/core/launch/bom_test.go b/internal/core/launch/bom_test.go new file mode 100644 index 000000000..b9c82e41b --- /dev/null +++ b/internal/core/launch/bom_test.go @@ -0,0 +1,14 @@ +package launch + +import "testing" + +// TestProseLinesDropsABOMLedFrontmatter: a BOM ahead of the opening rule does +// not turn a document's frontmatter into prose (iss-2608221126066379). +func TestProseLinesDropsABOMLedFrontmatter(t *testing.T) { + t.Parallel() + for _, pl := range proseLines([]byte("\ufeff---\ntitle: x\n---\nProse.\n")) { + if pl.text != "Prose." && pl.text != "" { + t.Fatalf("line %d read as prose: %q", pl.n, pl.text) + } + } +} diff --git a/internal/core/launch/gates.go b/internal/core/launch/gates.go index e543797f2..a3ae4867a 100644 --- a/internal/core/launch/gates.go +++ b/internal/core/launch/gates.go @@ -15,6 +15,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/mdrecord" "os" "path" @@ -257,7 +258,9 @@ func proseLines(data []byte) []proseLine { lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") var out []proseLine frontEnd := -1 // index of the closing "---"; -1 when there is no frontmatter - if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" { + // A BOM ahead of the opening rule is the file's encoding mark, trimmed at + // line 0 as every frontmatter reader trims it (iss-2608221126066379). + if len(lines) > 0 && strings.TrimSpace(frontmatter.TrimBOM(lines[0])) == "---" { for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == "---" { if isFrontmatter(lines[1:i]) { diff --git a/internal/core/lint/agentcontract.go b/internal/core/lint/agentcontract.go index 907ad1944..79d01cd06 100644 --- a/internal/core/lint/agentcontract.go +++ b/internal/core/lint/agentcontract.go @@ -437,12 +437,14 @@ func changedPaths(repoRoot, rangeSpec string) (map[string]bool, error) { // value, so it reads as present either way; the inline-list convention // (agents/README.md) is a style rule this parser does not adjudicate. func agentCapabilityScope(lines []string) map[string]string { - if start := frontmatterOpen(lines); start > 0 { - lines = lines[start:] - } - if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + start := frontmatterOpen(lines) + if start < 0 { return nil } + // frontmatterOpen has judged the opening line, a BOM ahead of it included; + // re-judging it here without the trim refused a BOM-led prompt's block + // (iss-2608221126066379). + lines = lines[start:] scope := map[string]string{} inScope, member := false, "" for i := 1; i < len(lines); i++ { diff --git a/internal/core/lint/bom_title_test.go b/internal/core/lint/bom_title_test.go new file mode 100644 index 000000000..c38f809bf --- /dev/null +++ b/internal/core/lint/bom_title_test.go @@ -0,0 +1,26 @@ +package lint + +import ( + "strings" + "testing" +) + +// TestRecordTitleReadsPastABOM: a BOM-led issue's title is its first body line, +// not its opening delimiter (iss-2608221126066379). +func TestRecordTitleReadsPastABOM(t *testing.T) { + t.Parallel() + lines := strings.Split("\ufeff---\nid: iss-1\n---\nThe first body line.\n", "\n") + if got := recordTitle(lines); got != "The first body line." { + t.Fatalf("recordTitle = %q, want the first body line", got) + } +} + +// TestAgentCapabilityScopeReadsPastABOM: a BOM-led prompt's capability scope +// is read from its block (iss-2608221126066379). +func TestAgentCapabilityScopeReadsPastABOM(t *testing.T) { + t.Parallel() + lines := strings.Split("\ufeff---\nname: x\ncapability_scope:\n designed_for: [review]\n---\nbody\n", "\n") + if got := agentCapabilityScope(lines); got["designed_for"] != "[review]" { + t.Fatalf("agentCapabilityScope = %v, want designed_for [review]", got) + } +} diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index faab8c332..f0c8bdbb7 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -2152,8 +2152,11 @@ func recordBodyStart(lines []string) int { // The leading comments are mdrecord's to locate: a private walk on a // `\n\nFix the detector\n=", "Fix the detector", 6}, + } { + t.Run(name, func(t *testing.T) { + doc := "---\nid: prn-p\n---\n\n" + tc.title + "\n\n**The rule.** Fix the class.\n" + st, ok := FindPrincipleStatement(strings.Split(doc, "\n")) + if !ok { + t.Fatalf("no statement found in %q", doc) + } + if st.Title != tc.want || st.TitleLine != tc.line { + t.Errorf("title = %q at line %d, want %q at line %d", st.Title, st.TitleLine, tc.want, tc.line) + } + }) + } + // A `===` under a line that opens another block, or inside a fence, is no + // setext heading, and neither is the `---` form, which is an H2. + for name, body := range map[string]string{ + "H2 underline": "Fix the detector\n---\n", + "under a list": "- Fix the detector\n===\n", + "fenced": "```\nFix the detector\n===\n```\n", + "indented code": " Fix the detector\n===\n", + } { + t.Run(name, func(t *testing.T) { + doc := "---\nid: prn-p\n---\n\n" + body + "\n**The rule.** Fix the class.\n" + st, _ := FindPrincipleStatement(strings.Split(doc, "\n")) + if st.Title != "" { + t.Errorf("read the title %q from %q", st.Title, body) + } + }) + } + root := t.TempDir() + doc := strings.Replace(typedPrinciple("p", "causal", `"abcd lint"`, `"One against another."`, "[adr-1]", + "Fix the class, not the instance."), "# A principle\n", "A principle citing itd-79\n===\n", 1) + writeFile(t, root, prnDir+"/p.md", doc) + writeFile(t, root, "rec/decisions/adrs/0001-a.md", "---\nid: adr-1\n---\n# ADR-1\n") + fs := lintPrinciples(t, root) + if !findingWith(fs, filepath.Join(prnDir, "p.md"), rulePrincipleClaims, "title carries the record handle 'itd-79'") { + t.Errorf("a citation in a setext title is not refused: %v", rulesOf(fs, filepath.Join(prnDir, "p.md"))) + } +} + // TestHeadingShapedPrincipleHasNoStatement: the statement is the labelled // paragraph and nothing else, in both readers (iss-2609261039132350). A typed // principle that writes it as a `## The rule` heading carries no statement the diff --git a/internal/core/reading/principle_test.go b/internal/core/reading/principle_test.go index 1886228c3..f6c2df9d3 100644 --- a/internal/core/reading/principle_test.go +++ b/internal/core/reading/principle_test.go @@ -76,6 +76,16 @@ func TestLabelledParagraphCarriesTheTitle(t *testing.T) { } } +// TestSetextTitleTravelsAboveTheStatement: a title underlined with `===` is an +// H1 too, so the statement does not travel bare (iss-2609261140284421). +func TestSetextTitleTravelsAboveTheStatement(t *testing.T) { + doc := strings.Replace(principleDoc(defaultRule), "# Fix the detector\n", "Fix the detector\n================\n", 1) + text, _, _ := projectField(principleRel, doc, "The rule", KindPrinciple) + if !strings.HasPrefix(text, "# Fix the detector\n\n"+principleStatement) { + t.Errorf("the setext title did not travel above the statement: %q", text) + } +} + // TestLinksUnwrapInTheStatement: a link target is a citation and the label is // prose, so the target stays behind and the label travels. func TestLinksUnwrapInTheStatement(t *testing.T) { From a478425f355bd013f84158745aef40dfbab2bf3d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:10:25 +0100 Subject: [PATCH 14/78] =?UTF-8?q?chore:=20resolve=20iss-2609261140284421?= =?UTF-8?q?=20=E2=80=94=20setext=20principle=20title=20carried?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261140284421 Assisted-by: Claude:claude-opus-5-5 --- ...2609261140284421-setext-principle-title-not-carried.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261140284421-setext-principle-title-not-carried.md (62%) diff --git a/.abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md b/.abcd/work/issues/resolved/iss-2609261140284421-setext-principle-title-not-carried.md similarity index 62% rename from .abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md rename to .abcd/work/issues/resolved/iss-2609261140284421-setext-principle-title-not-carried.md index a1b40c1a0..ecb392976 100644 --- a/.abcd/work/issues/open/iss-2609261140284421-setext-principle-title-not-carried.md +++ b/.abcd/work/issues/resolved/iss-2609261140284421-setext-principle-title-not-carried.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review2-principles LOW" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/principles.go" +resolution: "FindPrincipleStatement reads a setext H1 (the paragraph above a === underline) as well as an ATX one, so the projection carries the title and principle_claims judges it" +impact: fix +resolved_by: + commit: "0e9a7c5b9" --- A principle whose H1 title is written in setext form (the title line underlined with ===) travels to a reading without its title: principleTitleRe in internal/core/lint/principles.go:127 matches ATX headings only, so the projection sends the statement paragraph bare, and a cold reader gets a sentence with no name. Both readers agree, so no gate disagrees; the promise that a principle is readable cold does not hold for that file shape. + +## Grounds + +- pursued: a principle titled in setext form now travels to a reading with its title above the statement; a setext-titled principle projected bare, or a citation in a setext title passing principle_claims, would show it wrong From 543100249dd6f63aeab1cba7ee53e04d89388761 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:10:27 +0100 Subject: [PATCH 15/78] fix(capture): a re-deferral past the same anchor keeps one section per cycle capture defer appended a second `## Deferral` section when a record was deferred twice past the same anchor, where the record's shape is one section per cycle. The verb now rewrites that cycle's section (its heading date and its `Deferred past :` line) in place; a deferral past a different anchor still appends. Chosen over refusing, because a refusal would send a corrected reason back to a hand edit of the record, which is what the verb exists to replace; the superseded wording stays in git history. A section of any other shape is a hand edit the verb does not own and is left alone. The capture command page and the brief's capture chapter say so. Refs: iss-2609251823555125 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 5 +- commands/capture.md | 2 + internal/core/capture/deferral.go | 36 ++++++++++- internal/core/capture/deferral_test.go | 59 ++++++++++++++++++- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 904c41ed6..4b141cf86 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -288,7 +288,10 @@ tag that is not the checkout's newest release tag, an empty reason, a record tha is not open, and a record whose grade is neither `major` nor `critical`, which the guard never blocks on. The grade is judged before the tag. A record deferred past an earlier anchor is deferred again: the pair is replaced and a new section -appended, so each cycle's deferral stays readable in the record. +appended, so each cycle's deferral stays readable in the record. A record +deferred again past the SAME anchor has the pair and that cycle's section +rewritten in place, so the body keeps one section per cycle +(iss-2609251823555125). **Marking an issue wontfix** records an explicit non-action decision and moves the issue to `wontfix/`. Grounds are optional here and override the recorded diff --git a/commands/capture.md b/commands/capture.md index d9709de8e..77075d3b7 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -432,6 +432,8 @@ since the last release and still open, and one sanctioned way past it is a deferral stated out loud. `defer` writes it: `deferred_after` (the anchor tag) and `deferral_reason` in the record's frontmatter, and a dated `## Deferral ` section appended to its body. The record stays in `open/`. +A second deferral past the same anchor replaces that cycle's pair and section +rather than adding another, so the body carries one section per cycle. Report the `id`, `deferred_after` and `deferral_reason` from the JSON, and tell the user that the waiver lapses when the next release re-anchors, so it must be renewed then or the finding fixed. Report `redacted` whenever it is non-zero. diff --git a/internal/core/capture/deferral.go b/internal/core/capture/deferral.go index 66b6873ff..fdb2b653e 100644 --- a/internal/core/capture/deferral.go +++ b/internal/core/capture/deferral.go @@ -55,7 +55,8 @@ var deferrableSeverities = map[Severity]bool{SeverityMajor: true, SeverityCritic // record that is not open, and a grade the guard never blocks on. A record // already deferred past an earlier anchor is re-deferred: the pair is replaced // and a new body section is appended, so the history of each deferral stays in -// the record. +// the record. A record already deferred past the SAME anchor has the pair and +// that cycle's section replaced, so the body keeps one section per cycle. func Defer(req DeferRequest) (DeferResult, error) { repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) if err != nil { @@ -142,14 +143,43 @@ func Defer(req DeferRequest) (DeferResult, error) { return result, nil } -// appendDeferralSection appends one dated `## Deferral` section to the record, +// appendDeferralSection writes one dated `## Deferral` section into the record, // the body half of a deferral's shape: the frontmatter pair is what the cut // reads, and the section is what a reader of the record sees, one per cycle. +// +// One per cycle is what the section is, so a second deferral past the SAME +// anchor (a corrected reason, or the verb run twice) rewrites that cycle's +// section in place rather than appending a second one (iss-2609251823555125); +// the superseded wording stays in git's history, where every earlier revision of +// a record lives. A deferral past a different anchor appends, so each cycle's +// section stays in the record. func appendDeferralSection(content, date, after, reason string) string { + heading, line := "## Deferral "+date, "Deferred past "+after+": "+reason + if i := sameCycleDeferral(content, after); i >= 0 { + lines := strings.Split(content, "\n") + lines[i], lines[i+2] = heading, line + return strings.Join(lines, "\n") + } if !strings.HasSuffix(content, "\n") { content += "\n" } - return content + "\n## Deferral " + date + "\n\nDeferred past " + after + ": " + reason + "\n" + return content + "\n" + heading + "\n\n" + line + "\n" +} + +// sameCycleDeferral returns the line index of the last `## Deferral` heading +// whose section is the one this verb writes for anchor — the heading, a blank +// line, then `Deferred past : ` — or -1 when the record carries none. A +// section of any other shape is a hand edit the verb does not own, so it is left +// alone and the new section appended. +func sameCycleDeferral(content, anchor string) int { + lines := strings.Split(content, "\n") + for i := len(lines) - 3; i >= 0; i-- { + if strings.HasPrefix(lines[i], "## Deferral ") && lines[i+1] == "" && + strings.HasPrefix(lines[i+2], "Deferred past "+anchor+": ") { + return i + } + } + return -1 } // requireCurrentAnchor refuses a tag that is not the checkout's newest release diff --git a/internal/core/capture/deferral_test.go b/internal/core/capture/deferral_test.go index d837add41..b5e9ded65 100644 --- a/internal/core/capture/deferral_test.go +++ b/internal/core/capture/deferral_test.go @@ -15,7 +15,15 @@ import ( // name — holding one open major record and one open minor record. func deferralLedger(t *testing.T) (repo, ir, major, minor string) { t.Helper() - r := gittest.NewRepo(t) + _, repo, ir, major, minor = deferralLedgerRepo(t) + return repo, ir, major, minor +} + +// deferralLedgerRepo is deferralLedger with the repository handle, for a test +// that cuts a later tag. +func deferralLedgerRepo(t *testing.T) (r *gittest.Repo, repo, ir, major, minor string) { + t.Helper() + r = gittest.NewRepo(t) r.Commit("root") r.Git("tag", "v0.1.0") repo = r.Root() @@ -28,7 +36,7 @@ func deferralLedger(t *testing.T) (repo, ir, major, minor string) { } return res.ID } - return repo, ir, mk(SeverityMajor, "big"), mk(SeverityMinor, "small") + return r, repo, ir, mk(SeverityMajor, "big"), mk(SeverityMinor, "small") } // TestDeferWritesTheWaiverPairAndABodySection is iss-2609181223260994: the @@ -127,3 +135,50 @@ func TestADeferralTheVerbWritesIsOneTheCutHonours(t *testing.T) { t.Fatalf("the cut did not honour the verb's deferral: %+v", g) } } + +// TestReDeferringPastTheSameAnchorKeepsOneSection is iss-2609251823555125: the +// body carries one `## Deferral` section per cycle, so a second deferral past +// the SAME anchor — a corrected reason, or the verb run twice — replaces that +// cycle's section rather than appending a second one. A deferral past a later +// anchor still appends, so each cycle's history stays in the record. +func TestReDeferringPastTheSameAnchorKeepsOneSection(t *testing.T) { + r, repo, ir, major, _ := deferralLedgerRepo(t) + deferralNow = func() time.Time { return time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC) } + t.Cleanup(func() { deferralNow = time.Now }) + if _, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: major, After: "v0.1.0", Reason: "the first reason"}); err != nil { + t.Fatal(err) + } + deferralNow = func() time.Time { return time.Date(2026, 9, 26, 10, 0, 0, 0, time.UTC) } + if _, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: major, After: "v0.1.0", Reason: "the corrected reason"}); err != nil { + t.Fatal(err) + } + raw := readRaw(t, ir, major) + if n := strings.Count(raw, "\n## Deferral "); n != 1 { + t.Fatalf("two deferrals past one anchor left %d `## Deferral` sections, want 1:\n%s", n, raw) + } + for _, want := range []string{ + "\ndeferral_reason: \"the corrected reason\"\n", + "\n## Deferral 2026-09-26\n\nDeferred past v0.1.0: the corrected reason\n", + } { + if !strings.Contains(raw, want) { + t.Errorf("the re-deferred record lacks %q:\n%s", want, raw) + } + } + if strings.Contains(raw, "the first reason") { + t.Errorf("the superseded reason for the same cycle survived:\n%s", raw) + } + + // The next cycle appends: its section is new history, not a correction. + r.Commit("next") + r.Git("tag", "v0.2.0") + if _, err := Defer(DeferRequest{RepoRoot: repo, IssuesRoot: ir, ID: major, After: "v0.2.0", Reason: "the next cycle's reason"}); err != nil { + t.Fatal(err) + } + raw = readRaw(t, ir, major) + if n := strings.Count(raw, "\n## Deferral "); n != 2 { + t.Fatalf("a deferral past a later anchor left %d sections, want 2 (one per cycle):\n%s", n, raw) + } + if !strings.Contains(raw, "Deferred past v0.1.0: the corrected reason\n") || !strings.Contains(raw, "Deferred past v0.2.0: the next cycle's reason\n") { + t.Errorf("each cycle's section must survive:\n%s", raw) + } +} From 60d86d321bc3a7da079e35aa3859068bfe4314b1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:10:34 +0100 Subject: [PATCH 16/78] =?UTF-8?q?chore:=20resolve=20iss-2609251823555125?= =?UTF-8?q?=20=E2=80=94=20one=20deferral=20section=20per=20cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251823555125 Assisted-by: Claude:claude-opus-5-5 --- ...efer-past-the-same-anchor-appends-a-second-deferral.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md (51%) diff --git a/.abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md b/.abcd/work/issues/resolved/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md similarity index 51% rename from .abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md rename to .abcd/work/issues/resolved/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md index fd8a2040d..4927c7653 100644 --- a/.abcd/work/issues/open/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.md +++ b/.abcd/work/issues/resolved/iss-2609251823555125-capture-defer-past-the-same-anchor-appends-a-second-deferral.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: "capture defer past the same anchor rewrites that cycle's Deferral section in place instead of appending a second; a later anchor still appends" +impact: fix +resolved_by: + commit: "543100249" --- capture defer past the SAME anchor appends a second ## Deferral section (internal/core/capture/deferral.go:114), where the doc says one per cycle (review-capture 3). + +## Grounds + +- pursued: a record deferred twice past one anchor carries one Deferral section naming the latest reason, and a later anchor adds a second; TestReDeferringPastTheSameAnchorKeepsOneSection failing would show it wrong From 20da9778cdd60f4a07a3b4878ce5c065a2acabd1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:11:02 +0100 Subject: [PATCH 17/78] test(capture): pin the os.Root escape classification to ErrPathUnsafe mapEscape classifies os.Root's escape by matching the text "path escapes from parent", because the os package does not export the error, and nothing pinned the match: both race tests asserted only err != nil, so a Go release that rewords the message would degrade ErrPathUnsafe to a generic error with every test green. Both race tests now assert errors.Is(err, ErrPathUnsafe), and a direct test feeds mapEscape the error a real os.Root escape returns. Refs: iss-2609251823559111 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/ledgerroot_test.go | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/core/capture/ledgerroot_test.go b/internal/core/capture/ledgerroot_test.go index 2c9d01f31..b24eadb4b 100644 --- a/internal/core/capture/ledgerroot_test.go +++ b/internal/core/capture/ledgerroot_test.go @@ -1,6 +1,7 @@ package capture import ( + "errors" "os" "path/filepath" "strings" @@ -62,6 +63,12 @@ func TestLedgerMkdirCannotBeRedirectedOutsideTheCheckout(t *testing.T) { if err == nil { t.Fatal("a capture whose ledger ancestor was swapped for an outside symlink succeeded") } + // The refusal is the ledger's own path-unsafe sentinel, not merely an error: + // mapEscape classifies os.Root's escape by its message, and this pins the + // match against the Go release in use (iss-2609251823559111). + if !errors.Is(err, ErrPathUnsafe) { + t.Fatalf("the swapped-ancestor mkdir was refused with %v, want ErrPathUnsafe", err) + } if entries, _ := os.ReadDir(outside); len(entries) != 0 { t.Fatalf("the ledger walk created %d entr(ies) outside the checkout: %v", len(entries), entries) } @@ -97,9 +104,33 @@ func TestLedgerRecordWriteCannotBeRedirectedOutsideTheCheckout(t *testing.T) { if err == nil { t.Fatal("a capture whose ledger ancestor was swapped at the write succeeded") } + if !errors.Is(err, ErrPathUnsafe) { + t.Fatalf("the swapped-ancestor write was refused with %v, want ErrPathUnsafe", err) + } for _, f := range walkFiles(t, outside) { if strings.HasSuffix(f, ".md") || strings.Contains(filepath.Base(f), "abcd-tmp") { t.Fatalf("the record write landed outside the checkout: %s", f) } } } + +// TestMapEscapeClassifiesTheRealOSRootEscape pins mapEscape's message match to +// the error os.Root actually returns on the Go release in use. The os package +// does not export its escape error, so the match is by text; a release that +// rewords it would still refuse the write but degrade ErrPathUnsafe to a +// generic error, and this test is what notices (iss-2609251823559111). +func TestMapEscapeClassifiesTheRealOSRootEscape(t *testing.T) { + base := t.TempDir() + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + defer root.Close() + _, escErr := root.Open("../outside") + if escErr == nil { + t.Fatal("os.Root opened a path outside its root") + } + if got := mapEscape(escErr, filepath.Join(base, "..", "outside")); !errors.Is(got, ErrPathUnsafe) { + t.Fatalf("mapEscape(%v) = %v, want ErrPathUnsafe", escErr, got) + } +} From 1c894f73688ea06790cb4c3b04996874d770fbde Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:11:12 +0100 Subject: [PATCH 18/78] =?UTF-8?q?chore:=20resolve=20iss-2609251823559111?= =?UTF-8?q?=20=E2=80=94=20os.Root=20escape=20classification=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251823559111 Assisted-by: Claude:claude-opus-5-5 --- ...he-capture-ledger-s-os-root-escape-is-classified-by.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md (59%) diff --git a/.abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md b/.abcd/work/issues/resolved/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md similarity index 59% rename from .abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md rename to .abcd/work/issues/resolved/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md index ef239c7b6..9166d57da 100644 --- a/.abcd/work/issues/open/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.md +++ b/.abcd/work/issues/resolved/iss-2609251823559111-the-capture-ledger-s-os-root-escape-is-classified-by.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: "both ledger race tests assert errors.Is(err, ErrPathUnsafe) and a direct test feeds mapEscape a real os.Root escape, so a reworded Go message fails the capture tests" +impact: internal +resolved_by: + commit: "20da9778c" --- The capture ledger's os.Root escape is classified by matching the string 'path escapes from parent' (internal/core/capture/ledgerroot.go:59-64), and nothing pins the match: ledgerroot_test.go:62 and :97 assert only err != nil, so a Go release that rewords the message would silently degrade ErrPathUnsafe to a generic error (review-capture 1). Assert errors.Is(err, ErrPathUnsafe) in both race tests. + +## Grounds + +- pursued: a Go release that rewords the os.Root escape text turns TestMapEscapeClassifiesTheRealOSRootEscape and the write-race test RED; a mutant of the match string leaving the capture tests green would show it wrong From 698f83df9967b0a453f4940d6e41b2ece9f3521d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:12:44 +0100 Subject: [PATCH 19/78] fix(lint): stand a widening summary down past an admitted but unreadable answer The widening-run summary took the admitted case before the stand-down check, so a run whose only admitted proposal carried a contested, cyclic, unsafe or illegible disposition still reported "admitted 1, outstanding []", against the WideningRun doc: such a run supports no count. The stand-down case now comes first. Refs: iss-2609251842112266 Assisted-by: Claude:claude-opus-5-5 --- .../core/lint/reading_outstanding_test.go | 34 +++++++++++++++++++ internal/core/lint/readingoutstanding.go | 9 +++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/core/lint/reading_outstanding_test.go b/internal/core/lint/reading_outstanding_test.go index 9e9dd2f04..65a957bec 100644 --- a/internal/core/lint/reading_outstanding_test.go +++ b/internal/core/lint/reading_outstanding_test.go @@ -1174,6 +1174,40 @@ func TestWideningRunSummaryStandsDownOnAnUnreadableRun(t *testing.T) { } } +// An admission does not buy a count past an answer the walk could not read +// (iss-2609251842112266): a run whose only admitted proposal carries a +// contested or illegible disposition supports no count, so its summary stands +// down as the WideningRun doc promises, rather than reporting "admitted 1, +// outstanding []" over a disposition nobody could weigh. +func TestWideningRunSummaryStandsDownOnAnAdmittedButContestedItem(t *testing.T) { + const run, item = "rdg-2608300000000001", "rdi-2608300000000011" + for name, write := range map[string]func(root string){ + "contested": func(root string) { + dispositionRecord(t, root, item, "dsp-2608300000000021", issueschema.DispositionAccepted) + dispositionRecord(t, root, item, "dsp-2608300000000022", issueschema.DispositionAccepted) + }, + "illegible": func(root string) { + // A duplicated top-level key: malformed to every reader of this ledger. + writeFile(t, root, ".abcd/work/issues/dispositions/"+item+"/dsp-2608300000000021.md", + "---\nschema_version: 1\nid: \"dsp-2608300000000021\"\nid: \"dsp-2608300000000021\"\n"+ + "item: \""+item+"\"\nstate: \"accepted\"\ndisposition_grounds: \"a\"\n---\n\n") + }, + } { + t.Run(name, func(t *testing.T) { + root := readingLedger(t, run, item, "widening") + admissionRecord(t, root, run, "adm-2608300000000031", item) + write(root) + report, err := ReadReadingOutstanding(root, ".abcd/work/issues") + if err != nil { + t.Fatal(err) + } + if len(report.WideningRuns) != 0 { + t.Fatalf("WideningRuns = %+v, want the summary stood down", report.WideningRuns) + } + }) + } +} + // The summary is a report line, pinned at info whatever the configuration asks. func TestWideningRunSummaryIsInfoNotBlocker(t *testing.T) { root := readingLedger(t, "rdg-2608300000000001", "rdi-2608300000000011", "widening") diff --git a/internal/core/lint/readingoutstanding.go b/internal/core/lint/readingoutstanding.go index cda9a67b1..d1e2fd6b5 100644 --- a/internal/core/lint/readingoutstanding.go +++ b/internal/core/lint/readingoutstanding.go @@ -444,12 +444,17 @@ func ReadReadingOutstanding(repoRoot, issuesDir string) (OutstandingReadings, er report.OpenHolds = append(report.OpenHolds, answer.holds...) if widening { + // The stand-down comes first: an admission does not buy a count + // past an answer the walk could not read, so an admitted proposal + // carrying an unreadable, contested, cyclic or illegible + // disposition stands the run's summary down like any other + // (iss-2609251842112266). switch { - case admissions.admits(run.Name(), item): - summary.Admitted++ case len(answer.unsafe) > 0 || answer.cyclic || len(answer.contested) > 1 || (answer.standing != nil && !answer.standing.wellFormed): standDown = true + case admissions.admits(run.Name(), item): + summary.Admitted++ case answer.standing != nil && answer.standing.state == issueschema.DispositionDeclined: summary.Declined++ case answer.standing != nil && answer.standing.state == issueschema.DispositionHeld: From 3b610b63f998b3252392382fb6662ab9d31bf517 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:12:46 +0100 Subject: [PATCH 20/78] =?UTF-8?q?chore:=20resolve=20iss-2609251842112266?= =?UTF-8?q?=20=E2=80=94=20widening=20summary=20stand-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251842112266 Assisted-by: Claude:claude-opus-5-5 --- ...idening-run-summary-s-stand-down-is-bypassed-for-an.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md (60%) diff --git a/.abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md b/.abcd/work/issues/resolved/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md similarity index 60% rename from .abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md rename to .abcd/work/issues/resolved/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md index 83e7e29a1..4edb01cef 100644 --- a/.abcd/work/issues/open/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.md +++ b/.abcd/work/issues/resolved/iss-2609251842112266-the-widening-run-summary-s-stand-down-is-bypassed-for-an.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/lint/readingoutstanding.go" +resolution: "the widening-run summary checks the stand-down before counting an admission, so an admitted proposal with a contested, cyclic, unsafe or illegible disposition stands the run's summary down" +impact: fix +resolved_by: + commit: "698f83df9" --- The widening-run summary's stand-down is bypassed for an admitted item: internal/core/lint/readingoutstanding.go:421-425 takes the admissions.admits case before the unsafe/contested/cyclic check, so a run whose only acceptance is unreadable or contested still reports 'admitted 1, outstanding []', contradicting the type's own doc comment (lines 131-135; review-admission 1). + +## Grounds + +- pursued: a run whose admitted proposal carries an answer the walk cannot read reports no summary; a summary line reporting admitted 1 over a contested or illegible disposition would show it wrong From a6c630c39c0755561a0102ca53fbd6a9e9bcdf6a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:02 +0100 Subject: [PATCH 21/78] fix(cli): the capture verbs never print an absolute checkout path The ledger identity every capture verb reports (the `ledger` member in --json and the `ledger of` line on stderr, and the record dispatcher's for an iss-N) was home-redacted only, so a checkout outside HOME was printed as its full absolute path. It is now reduced to its directory name, the base-name rule scrubPaths already applies to an absolute path outside both identity roots, built from the existing fsutil.RedactHome rather than a second primitive. renderLedger silently skipped the `ledger` member for a result that is not a JSON object. Every caller passes a struct, so it is now an internal error rather than an envelope that loses its identity without a word; the splice itself stays, because it keeps the result's own member order. Refs: iss-2609251823560369 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 3 +- internal/surface/cli/capture_surface_test.go | 59 +++++++++++++++++++ internal/surface/cli/cli.go | 34 ++++++++--- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 4b141cf86..acc91b5b5 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -333,7 +333,8 @@ Every verb also says which checkout's ledger it addressed, and the record dispatcher says it for an issue id (iss-2609202053570475): one stderr line naming the checkout and its branch in the plain render, and a `ledger` member with `checkout` and `branch` in the machine-readable one. The checkout is written home-relative where -it can be. A record filed in another worktree is invisible here, and a refusal +it can be, and by its directory name where it cannot, so neither render carries an +absolute local path (iss-2609251823560369). A record filed in another worktree is invisible here, and a refusal that says "not found" without naming where it looked sends the reader to the wrong conclusion. diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index db6a84856..523f0fc7b 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "encoding/json" + "io" "io/fs" "os" "os/exec" @@ -1536,3 +1537,61 @@ func TestCaptureWontfixRefusesALockedBody(t *testing.T) { t.Fatalf("after the refusal the record sits in %v, want [open]", where) } } + +// TestTheLedgerIdentityNeverPrintsAnAbsoluteCheckout is iss-2609251823560369: +// the checkout a capture verb names was home-redacted only, so a checkout +// outside HOME was printed as a full absolute path — in --json and on stderr — +// in output that is pasted elsewhere. A checkout outside HOME is named by its +// directory name, the base-name rule the CLI's error scrub already applies to an +// absolute path outside both identity roots. +func TestTheLedgerIdentityNeverPrintsAnAbsoluteCheckout(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + repo := captureLedgerRepo(t) + gitCommitAt(t, repo, "root") + absForms := []string{repo} + if real, err := filepath.EvalSymlinks(repo); err == nil && real != repo { + absForms = append(absForms, real) + } + var env struct { + Ledger struct { + Checkout string `json:"checkout"` + } `json:"ledger"` + } + out := runCLI(t, "capture", "an observation outside the home", "--json") + if err := json.Unmarshal(out, &env); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out) + } + if env.Ledger.Checkout != filepath.Base(repo) { + t.Fatalf("ledger.checkout = %q, want the checkout's directory name %q", env.Ledger.Checkout, filepath.Base(repo)) + } + text := string(runCLI(t, "capture", "list", "--open")) + if !strings.Contains(text, "abcd capture: ledger of "+filepath.Base(repo)+" on branch main") { + t.Fatalf("the stderr identity line does not name the checkout by its directory name:\n%s", text) + } + for _, abs := range absForms { + for what, s := range map[string]string{"--json": string(out), "stderr": text} { + if strings.Contains(s, abs) { + t.Errorf("%s prints the absolute checkout path %s:\n%s", what, abs, s) + } + } + } +} + +// TestRenderLedgerRefusesANonObjectResult: the `ledger` member is added to an +// object envelope, and a result that is not an object has no member to add it +// to. It used to be skipped silently, dropping the identity the envelope exists +// to carry; it is an error instead (iss-2609251823560369). +func TestRenderLedgerRefusesANonObjectResult(t *testing.T) { + var buf bytes.Buffer + if err := renderLedger(&buf, true, t.TempDir(), []string{"a", "b"}, func(io.Writer) {}); err == nil { + t.Fatalf("a non-object result rendered without its ledger member:\n%s", buf.String()) + } + buf.Reset() + if err := renderLedger(&buf, true, t.TempDir(), struct{}{}, func(io.Writer) {}); err != nil { + t.Fatal(err) + } + var env map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &env); err != nil || env["ledger"] == nil { + t.Fatalf("an empty object result lost its ledger member: %v\n%s", err, buf.String()) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index fb5f6af74..59f29a318 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3815,7 +3815,8 @@ func captureLedgerRoot(cmd *cobra.Command) (string, error) { // ledgerIdentity names the checkout whose ledger a verb addressed and the // branch checked out there (iss-2609202053570475). The checkout is home- -// relative where it can be, so the line carries no developer-identity path. +// relative where it can be, and its directory name where it cannot, so the line +// never carries an absolute path (iss-2609251823560369). type ledgerIdentity struct { Checkout string `json:"checkout"` Branch string `json:"branch"` @@ -3823,6 +3824,12 @@ type ledgerIdentity struct { // ledgerIdentityOf reads root's identity: its home-redacted path, and the // branch git reports ("HEAD" when detached, "" when git cannot answer). +// +// A checkout outside HOME survives RedactHome whole, and printed whole it is an +// absolute local path in output a person pastes elsewhere. It is reduced to its +// directory name instead, the rule scrubPaths already applies to an absolute +// path outside both identity roots, so the two surfaces agree on what is safe +// to print. func ledgerIdentityOf(root string) ledgerIdentity { // symbolic-ref answers on an unborn branch too, where rev-parse cannot; it // fails only when HEAD is detached, which rev-parse then names. @@ -3834,7 +3841,11 @@ func ledgerIdentityOf(root string) ledgerIdentity { branch = "" } } - return ledgerIdentity{Checkout: fsutil.RedactHome(root), Branch: branch} + checkout := fsutil.RedactHome(root) + if filepath.IsAbs(checkout) { + checkout = filepath.Base(root) + } + return ledgerIdentity{Checkout: checkout, Branch: branch} } // branchPhrase renders the branch half of the identity line. @@ -3865,13 +3876,20 @@ func renderLedger(w io.Writer, asJSON bool, root string, v any, text func(io.Wri if err != nil { return err } - if n := len(body); n >= 2 && body[0] == '{' && body[n-1] == '}' { - sep := "," - if n == 2 { - sep = "" - } - body = append(append(append(body[:n-1:n-1], []byte(sep+`"ledger":`)...), ident...), '}') + // json.Marshal emits compact JSON, so an object result is exactly the bytes + // between its own braces and the member is appended before the closing one, + // keeping the result's member order. A result that is not an object has no + // member to carry the identity, and dropping it silently would ship the + // envelope without the one thing it exists to say — so it is an error. + n := len(body) + if n < 2 || body[0] != '{' || body[n-1] != '}' { + return fmt.Errorf("internal: a capture verb's --json result must be an object to carry its ledger member, got %T", v) + } + sep := "," + if n == 2 { + sep = "" } + body = append(append(append(body[:n-1:n-1], []byte(sep+`"ledger":`)...), ident...), '}') var buf bytes.Buffer if err := json.Indent(&buf, body, "", " "); err != nil { return err From 96f0ea48425392545ce576149ec91dc6bfc531a6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:05 +0100 Subject: [PATCH 22/78] =?UTF-8?q?chore:=20resolve=20iss-2609251823560369?= =?UTF-8?q?=20=E2=80=94=20no=20absolute=20checkout=20path=20in=20capture?= =?UTF-8?q?=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251823560369 Assisted-by: Claude:claude-opus-5-5 --- ...pture-verbs-json-and-stderr-print-the-checkout-path.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md (55%) diff --git a/.abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md b/.abcd/work/issues/resolved/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md similarity index 55% rename from .abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md rename to .abcd/work/issues/resolved/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md index c82162697..ff5cde8d9 100644 --- a/.abcd/work/issues/open/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.md +++ b/.abcd/work/issues/resolved/iss-2609251823560369-the-capture-verbs-json-and-stderr-print-the-checkout-path.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: "the capture verbs' ledger identity names a checkout outside HOME by its directory name, never its absolute path, and renderLedger refuses a non-object result instead of silently dropping the ledger member" +impact: fix +resolved_by: + commit: "a6c630c39" --- The capture verbs' --json and stderr print the checkout path through RedactHome only, so a checkout outside HOME is printed in full (internal/surface/cli/cli.go renderLedger), an absolute local path in output that may be pasted elsewhere; renderLedger also splices the ledger member by byte surgery on the trailing brace (review-capture 4). + +## Grounds + +- pursued: a capture verb run in a checkout outside HOME prints no absolute path in --json or on stderr; TestTheLedgerIdentityNeverPrintsAnAbsoluteCheckout failing, or an absolute checkout in either render, would show it wrong From f407dc13f97cedd4e8196e26bc8a6165b1b48b56 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:14:28 +0100 Subject: [PATCH 23/78] fix(lint): name a markdown-named link at a record store root record_schema passed silently over a link at a bucketed store root whose name ends in .md without being a record filename (notes.md pointing at a directory). A real directory of that name is reported; the link was not, because telling what it points at would mean following it. Every such link is now named without being followed; README.md and dot-names stay exempt, and a record-named link keeps its store-root finding. Refs: iss-2609261208193041 Assisted-by: Claude:claude-opus-5-5 --- internal/core/lint/guardedread_rules_test.go | 41 ++++++++++++++++++++ internal/core/lint/schema.go | 15 +++++++ 2 files changed, 56 insertions(+) diff --git a/internal/core/lint/guardedread_rules_test.go b/internal/core/lint/guardedread_rules_test.go index 306ce04fe..16f23adda 100644 --- a/internal/core/lint/guardedread_rules_test.go +++ b/internal/core/lint/guardedread_rules_test.go @@ -3,6 +3,7 @@ package lint import ( "os" "path/filepath" + "reflect" "strings" "syscall" "testing" @@ -269,6 +270,46 @@ func TestRecordSchemaNamesEveryUndeclaredLink(t *testing.T) { } } +// A markdown-named link at a bucketed store's root that is no record filename +// is named too, without being followed (iss-2609261208193041): telling whether +// `notes.md` points at a directory or a file would mean following it, so every +// such link is reported, whatever it points at. The store's README.md is the +// one link the root may carry, and a dot-named link is tooling state. +func TestRecordSchemaNamesAMarkdownNamedLinkAtAStoreRoot(t *testing.T) { + cfg := Config{Rules: map[string]RuleConfig{ + ruleRecordSchema: {Enabled: true, Severity: "blocker", RecordStores: map[string]string{"iss": "work/issues"}}, + }} + forged := map[string]string{"iss-9-x.md": "---\nid: \"iss-9\"\nseverity: \"SECRET-TARGET\"\n---\n"} + root := t.TempDir() + writeFile(t, root, "work/issues/resolved/iss-5-a.md", "---\nid: \"iss-5\"\n---\n") + symlinkDirOut(t, root, "work/issues/notes.md", forged) + writeFile(t, root, "elsewhere/target.md", "---\nid: \"iss-9\"\nseverity: \"SECRET-TARGET\"\n---\n") + for _, name := range []string{"filed.md", "README.md", ".scratch.md"} { + if err := os.Symlink(filepath.Join(root, "elsewhere", "target.md"), filepath.Join(root, "work", "issues", name)); err != nil { + t.Fatal(err) + } + } + fs, err := lintWithin(t, cfg, root) + if err != nil { + t.Fatal(err) + } + named := map[string]int{} + for _, f := range fs { + if strings.Contains(f.Message, "SECRET-TARGET") || strings.Contains(f.File, "iss-9") { + t.Fatalf("something behind a link was read: %+v", f) + } + if f.RuleID == ruleRecordSchema && filepath.Dir(f.File) == filepath.Join("work", "issues") { + named[filepath.Base(f.File)]++ + if !strings.Contains(f.Message, "is a link at the issue store root") { + t.Errorf("finding on %s: %q", f.File, f.Message) + } + } + } + if want := map[string]int{"notes.md": 1, "filed.md": 1}; !reflect.DeepEqual(named, want) { + t.Fatalf("links named at the store root = %v, want %v; findings %+v", named, want, fs) + } +} + // A link at a store root that is itself a CONFIGURED store root is scanned by // that store, exactly as a real nested root is, so its parent does not call it an // undeclared bucket. diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index f81c6db5a..dbb488d6c 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -1852,6 +1852,21 @@ func scanRecordStores(repoRoot string, cfg RuleConfig) ([]schemaRecord, []Findin } continue } + // A markdown-named link that is no record filename is named as well + // (iss-2609261208193041): whether `notes.md` points at a directory or + // a file could be told only by following it, which the walk never + // does, so every such link is reported whatever it points at. The + // store's README.md is the one link the root may carry, and a link + // with a record filename falls to the store-root record leg below. + if e.Type()&fs.ModeSymlink != 0 && !strings.EqualFold(e.Name(), "README.md") && + !store.fileNumRe.MatchString(e.Name()) { + if !strings.HasPrefix(e.Name(), ".") { + add(rel, "'"+e.Name()+"' is a link at the "+store.noun+" store root; the gate never follows a link, "+ + "so whether it points at a record or at a bucket nobody declared ("+store.bucketDesc()+ + "), nothing behind it is checked") + } + continue + } if e.IsDir() { // A dot-directory is tooling state (an editor's, a scanner's), never // a lifecycle the record authored — the record's own buckets are all From fcb80536ada1f7d23463f4bbdfc15daeb4845524 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:14:31 +0100 Subject: [PATCH 24/78] =?UTF-8?q?chore:=20resolve=20iss-2609261208193041?= =?UTF-8?q?=20=E2=80=94=20store-root=20markdown=20link=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261208193041 Assisted-by: Claude:claude-opus-5-5 --- ...208193041-record-schema-md-named-link-at-store-root.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261208193041-record-schema-md-named-link-at-store-root.md (66%) diff --git a/.abcd/work/issues/open/iss-2609261208193041-record-schema-md-named-link-at-store-root.md b/.abcd/work/issues/resolved/iss-2609261208193041-record-schema-md-named-link-at-store-root.md similarity index 66% rename from .abcd/work/issues/open/iss-2609261208193041-record-schema-md-named-link-at-store-root.md rename to .abcd/work/issues/resolved/iss-2609261208193041-record-schema-md-named-link-at-store-root.md index 9cc643470..8a9c5c931 100644 --- a/.abcd/work/issues/open/iss-2609261208193041-record-schema-md-named-link-at-store-root.md +++ b/.abcd/work/issues/resolved/iss-2609261208193041-record-schema-md-named-link-at-store-root.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: fix4-lintA sweep" origin: researcher-authored production_mode: hand-written found_at: "internal/core/lint/schema.go" +resolution: "record_schema names every markdown-named link at a bucketed store root that is not README.md or a record filename, without following it" +impact: fix +resolved_by: + commit: "f407dc13f" --- record_schema still passes silently over one link shape at a record store root: a link whose name ends in .md but does not match the record filename pattern (for example notes.md pointing at a directory). A real directory with that name is reported; the link is not, because telling whether it points at a directory would mean following it, which the walk never does. Reporting every link at a store root other than README.md, without following it, closes the shape. + +## Grounds + +- pursued: a notes.md link at a store root draws one finding whatever it points at, and nothing behind it is read; such a link drawing no finding, or its target's content surfacing, would show it wrong From 38958d6d4718d14ec9836d8ab19ad9734dfa48e4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:18:01 +0100 Subject: [PATCH 25/78] test(mdrecord): read fence runs and first-byte toggles the delimiter scan misses TestNoSecondFenceRule's scan is delimiter-only, so a private toggle written as a regexp literal matching a fence run, or as a comparison of a line's first byte against a backtick or tilde, escaped it. A second detector reads the parsed source for both shapes, pinned per file with a reason like the first; a pattern spelling a delimiter stays the first scan's, and a pattern taking a run of letters whole is an alphabet, not a reader. The first test's doc now says what each reaches. Refs: iss-2609251600029607 Assisted-by: Claude:claude-opus-5-5 --- .../core/mdrecord/fence_canonical_test.go | 7 +- .../mdrecord/fence_shape_canonical_test.go | 290 ++++++++++++++++++ 2 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 internal/core/mdrecord/fence_shape_canonical_test.go diff --git a/internal/core/mdrecord/fence_canonical_test.go b/internal/core/mdrecord/fence_canonical_test.go index 98f7cb6f7..262c81b35 100644 --- a/internal/core/mdrecord/fence_canonical_test.go +++ b/internal/core/mdrecord/fence_canonical_test.go @@ -59,8 +59,11 @@ var fenceWriters = map[string]fenceWriter{ // run tracking kept in a variable called `open`, and a flip written as an // if/else — carried none of them and escaped (iss-2609251510124162). Every // writer of a delimiter is now named with a reason, which is a short list: most -// of the tree never spells one. A delimiter assembled at run time rather than -// written as a literal is outside its reach, and is left to review. +// of the tree never spells one. Two toggles spell no delimiter at all, a regexp +// literal matching a fence RUN and a comparison of a line's first byte, and +// TestNoFenceRunReaderOutsideMdrecord reads the parsed source for those +// (iss-2609251600029607). A delimiter assembled at run time from non-constant +// parts is outside both, and is left to review. func TestNoSecondFenceRule(t *testing.T) { root := filepath.Join("..", "..", "..") // internal/core/mdrecord -> repository root var offenders []string diff --git a/internal/core/mdrecord/fence_shape_canonical_test.go b/internal/core/mdrecord/fence_shape_canonical_test.go new file mode 100644 index 000000000..530318d06 --- /dev/null +++ b/internal/core/mdrecord/fence_shape_canonical_test.go @@ -0,0 +1,290 @@ +package mdrecord + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +// fenceShapeReaders names every non-test Go file outside this package that +// reads a fence character in one of the two shapes TestNoSecondFenceRule's +// delimiter scan cannot see (iss-2609251600029607), with the number of such +// reads it holds and the reason none of them is a second fence rule. The count +// is pinned for the reason fenceWriters pins its own: a toggle written into a +// file already on the list changes the count and fails until a reviewer reads +// the new reason. The default for a file this test names is to route it +// through Read. +var fenceShapeReaders = map[string]fenceWriter{ + "internal/core/positioning/check.go": {1, "emphasisRe strips inline emphasis and code markers from one tagline candidate, so a bolded tagline still reads as the tagline; it judges one string and tracks no lines"}, + "internal/core/ideate/render.go": {2, "blockText escapes a paragraph's first byte when it would open a block (a backtick run, a tilde, a heading or list marker), so the rendered record keeps its text as prose; it judges one string and tracks no lines"}, + "internal/termsafe/prose.go": {1, "OpensBalancedCodeSpan asks whether a string opens with a code span closed on the same line, which by construction opens no fence; it judges one string and tracks no lines"}, +} + +// fenceRunProbes are the runs a fence opens with: a pattern that matches one +// of them, and names a fence character itself, reads fences. +var fenceRunProbes = []string{"```", "~~~"} + +// TestNoFenceRunReaderOutsideMdrecord is TestNoSecondFenceRule's second half +// (iss-2609251600029607). That scan is delimiter-only, so two spellings of a +// private toggle escaped it: a regexp literal whose pattern matches a fence +// RUN without spelling a three-character delimiter (`^ {0,3}(` + "`" + `+|~+)`, +// with the length checked afterwards), and a comparison of a line's FIRST +// byte against a backtick or a tilde (`ln[0] == '~'`, or a `switch ln[0]` with +// such a case). Both are read from the parsed source, so a comment or an +// unrelated string holding the characters is not a read. A pattern spelling a +// delimiter is the delimiter scan's and is not claimed twice, and a pattern +// that takes a run of letters whole as readily as a fence run is an alphabet +// that admits the fence characters, not a reader of them. +// +// Its reach ends where the source stops being a literal and a comparison stops +// being at the first byte: a pattern assembled at run time from non-constant +// parts, a comparison at an index other than a constant 0 (a byte read after an +// indent walk), and a one-character prefix test (`strings.HasPrefix(ln, "~")`, +// which the tree spells for code spans and home paths far more often than for +// fences) are outside it and are left to review. +func TestNoFenceRunReaderOutsideMdrecord(t *testing.T) { + root := filepath.Join("..", "..", "..") // internal/core/mdrecord -> repository root + var offenders []string + seen := map[string]bool{} + for _, dir := range []string{"internal", "cmd"} { + err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path == filepath.Join(root, "internal", "core", "mdrecord") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + reads, err := fenceShapeReads(path) + if err != nil { + return err + } + if len(reads) == 0 { + return nil + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + seen[rel] = true + w, ok := fenceShapeReaders[rel] + switch { + case !ok: + offenders = append(offenders, fmt.Sprintf("%s (reads a fence character at %s and is not allowlisted)", + rel, strings.Join(reads, ", "))) + case w.count != len(reads): + offenders = append(offenders, fmt.Sprintf("%s (reads a fence character %d time(s), at %s; the allowlist names %d)", + rel, len(reads), strings.Join(reads, ", "), w.count)) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } + } + if len(offenders) > 0 { + sort.Strings(offenders) + t.Errorf("fence runs read outside mdrecord (route through mdrecord.Read, or allowlist with a reason and the count):\n %s", + strings.Join(offenders, "\n ")) + } + for rel := range fenceShapeReaders { + if !seen[rel] { + t.Errorf("%s is allowlisted but no longer reads a fence character; remove the entry", rel) + } + } +} + +// TestFenceShapeReadsAreSeen pins the detector against the spellings the +// delimiter scan missed, and against the neighbours it must leave alone. +func TestFenceShapeReadsAreSeen(t *testing.T) { + for name, tc := range map[string]struct { + src string + want int + }{ + "run regexp": {"var r = regexp.MustCompile(`^ {0,3}(` + \"`\" + `+|~+)`)", 1}, + "tilde run regexp": {"var r = regexp.MustCompile(`^\\s*~+`)", 1}, + "class run regexp": {"var r = regexp.MustCompile(\"^[`~]+\")", 1}, + "escaped run regexp": {"var r = regexp.MustCompile(`^\\x60+`)", 1}, + "first byte ==": {"func f(s string) bool { return s[0] == '~' }", 1}, + "first byte !=": {"func f(s string) bool { return '`' != s[0] }", 1}, + "switch on first byte": {"func f(s string) { switch s[0] { case '#', '`': } }", 1}, + "code span regexp": {"var r = regexp.MustCompile(\"`[^`]+`\")", 0}, + "negated class": {"var r = regexp.MustCompile(`[^~]`)", 0}, + "no fence char": {"var r = regexp.MustCompile(`^.*$`)", 0}, + "identifier alphabet": {"var r = regexp.MustCompile(`^[A-Za-z0-9~]+$`)", 0}, + "delimiter spelled": {"var r = regexp.MustCompile(\"^ {0,3}(`{3,}|~{3,})\")", 0}, + "inner byte": {"func f(s string, i int) bool { return s[i] == '~' }", 0}, + "other first byte": {"func f(s string) bool { return s[0] == '#' }", 0}, + "comment only": {"// ln[0] == '~' and regexp.MustCompile(`~+`)\nvar x = 1", 0}, + "run-time pattern": {"func f(p string) { regexp.MustCompile(p + `+`) }", 0}, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "x.go") + if err := os.WriteFile(path, []byte("package x\n\n"+tc.src+"\n"), 0o644); err != nil { + t.Fatal(err) + } + reads, err := fenceShapeReads(path) + if err != nil { + t.Fatal(err) + } + if len(reads) != tc.want { + t.Errorf("reads = %v, want %d", reads, tc.want) + } + }) + } +} + +// fenceShapeReads parses one Go file and returns the position of every fence +// read in the two shapes: a regexp literal matching a fence run, and a +// first-byte comparison against a fence character. +func fenceShapeReads(path string) ([]string, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + var reads []string + at := func(n ast.Node) { + p := fset.Position(n.Pos()) + reads = append(reads, "line "+strconv.Itoa(p.Line)) + } + ast.Inspect(f, func(n ast.Node) bool { + switch n := n.(type) { + case *ast.CallExpr: + switch callName(n) { + case "regexp.MustCompile", "regexp.Compile", "regexp.MustCompilePOSIX", "regexp.CompilePOSIX": + if len(n.Args) == 1 { + if pat, ok := constString(n.Args[0]); ok && patternReadsFenceRun(pat) { + at(n) + } + } + } + case *ast.BinaryExpr: + if (n.Op == token.EQL || n.Op == token.NEQ) && + (firstByte(n.X) && fenceChar(n.Y) || firstByte(n.Y) && fenceChar(n.X)) { + at(n) + } + case *ast.SwitchStmt: + if n.Tag == nil || !firstByte(n.Tag) { + return true + } + for _, s := range n.Body.List { + cc, ok := s.(*ast.CaseClause) + if !ok { + continue + } + for _, e := range cc.List { + if fenceChar(e) { + at(e) + } + } + } + } + return true + }) + return reads, nil +} + +// callName is a call's `pkg.Func` spelling, or "" for any other callee. +func callName(c *ast.CallExpr) string { + sel, ok := c.Fun.(*ast.SelectorExpr) + if !ok { + return "" + } + id, ok := sel.X.(*ast.Ident) + if !ok { + return "" + } + return id.Name + "." + sel.Sel.Name +} + +// constString folds a string literal, or a `+` concatenation of them, to its +// value; ok is false when any part is not a literal. +func constString(e ast.Expr) (string, bool) { + switch e := e.(type) { + case *ast.BasicLit: + if e.Kind != token.STRING { + return "", false + } + s, err := strconv.Unquote(e.Value) + return s, err == nil + case *ast.ParenExpr: + return constString(e.X) + case *ast.BinaryExpr: + if e.Op != token.ADD { + return "", false + } + l, ok := constString(e.X) + if !ok { + return "", false + } + r, ok := constString(e.Y) + return l + r, ok + } + return "", false +} + +// patternReadsFenceRun reports whether a regexp pattern names a fence +// character and matches a whole fence run. A pattern spelling a delimiter is +// the delimiter scan's, and is not claimed twice. +func patternReadsFenceRun(pat string) bool { + if fenceDelimiterRe.MatchString(pat) { + return false + } + re, err := regexp.Compile(pat) + if err != nil { + return false + } + names := strings.ContainsAny(pat, "`~") || strings.Contains(strings.ToLower(pat), `\x60`) || + strings.Contains(strings.ToLower(pat), `\x7e`) + if !names { + return false + } + whole := func(run string) bool { + loc := re.FindStringIndex(run) + return loc != nil && loc[1]-loc[0] == len(run) + } + // A pattern that takes a run of plain letters whole as readily is a class + // admitting the fence characters among others (an identifier's alphabet), + // not a reader of fence runs. + if whole("aaa") { + return false + } + for _, run := range fenceRunProbes { + if whole(run) { + return true + } + } + return false +} + +// firstByte reports whether e indexes a value at the constant 0. +func firstByte(e ast.Expr) bool { + ix, ok := e.(*ast.IndexExpr) + if !ok { + return false + } + lit, ok := ix.Index.(*ast.BasicLit) + return ok && lit.Kind == token.INT && lit.Value == "0" +} + +// fenceChar reports whether e is a backtick or tilde character literal. +func fenceChar(e ast.Expr) bool { + lit, ok := e.(*ast.BasicLit) + if !ok || lit.Kind != token.CHAR { + return false + } + r, _, _, err := strconv.UnquoteChar(strings.Trim(lit.Value, "'"), '\'') + return err == nil && (r == '`' || r == '~') +} From 30080fedfeab96ee7cfe6e29df6a3d7dab148cec Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:18:04 +0100 Subject: [PATCH 26/78] =?UTF-8?q?chore:=20resolve=20iss-2609251600029607?= =?UTF-8?q?=20=E2=80=94=20fence-run=20and=20first-byte=20toggles=20detecte?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251600029607 Assisted-by: Claude:claude-opus-5-5 --- ...osecondfencerule-s-delimiter-only-detector-misses-a.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-a.md (62%) diff --git a/.abcd/work/issues/open/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-a.md b/.abcd/work/issues/resolved/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-a.md similarity index 62% rename from .abcd/work/issues/open/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-a.md rename to .abcd/work/issues/resolved/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-a.md index e4a8b1fc2..f00b81414 100644 --- a/.abcd/work/issues/open/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-a.md +++ b/.abcd/work/issues/resolved/iss-2609251600029607-testnosecondfencerule-s-delimiter-only-detector-misses-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/mdrecord" +resolution: "TestNoFenceRunReaderOutsideMdrecord reads the parsed source for regexp literals matching a fence run and first-byte comparisons against a backtick or tilde, allowlisted per file with a count and reason; TestNoSecondFenceRule's doc names what each detector reaches" +impact: internal +resolved_by: + commit: "38958d6d4" --- TestNoSecondFenceRule's delimiter-only detector misses a private toggle written as a literal regexp that matches a run (for example a pattern of 0-3 spaces then a backtick or tilde run, with a length check) or as byte comparisons on the first character; its doc says a pattern 'assembled at run time' escapes, which understates a literal regexp. Extend the detector to regexp literals whose pattern can match a fence run, and to first-byte comparisons against a backtick or tilde, or narrow the doc to the truth. + +## Grounds + +- pursued: a private toggle in either shape now fails a detector; a planted regexp run toggle or ln[0] == '~' toggle passing both detectors would show it wrong From 819709856bb721d58e9423b12b6e6fd9949b239c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:20:17 +0100 Subject: [PATCH 27/78] fix(capture): the ordering gate holds a comparative run to the channel's pair The widening ordering gate read "committed comparative run" as any run.json under the readings family decoding to position comparative with a candidate_run, so an id-less, manifest-less two-key marker written by hand opened it. ComparativeRunFor now holds a run record that names the widening run to the pair the channel's ingest leaves: its run_id names its own directory, and the manifest the ingest promotes beside it (before it writes the record) agrees on the run id, the position and the candidate_run. A record naming the widening run that fails either is refused by name as ErrInvariantViolation, never read as "no run yet". Not checked, and why: whether git tracks the pair (the gate answers between an ingest and the commit that carries it, the order the channel is used in), and the manifest's hash against the record's manifest_sha256 (a writer who can place both files can compute the hash too, so it would add a step, not a bar). A writer with the tree can still forge the pair; the gate now names the channel's artefacts, which is what the spec's "committed comparative run whose manifest names rdg-N" states. The manifest's file name and its read ceiling move to issueschema (RunManifestFileName, RunArtefactReadLimit), which core/reading now reads, so the writer and the gate share one statement of each. A reading-package test joins the channel's real ingest, exercised and not, to the gate. Fixtures in capture, scribe, the CLI and the rehearsal eval write the pair. Refs: iss-2609251842111593 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 8 ++- commands/capture.md | 3 +- evals/coldreading_rehearsal_test.go | 17 ++++-- internal/core/capture/admit_test.go | 6 +- internal/core/capture/itemfate.go | 48 +++++++++++++++- internal/core/capture/itemfate_test.go | 55 +++++++++++++++++-- internal/core/capture/reading_test.go | 15 ++++- internal/core/issueschema/ledgerdirs.go | 14 +++++ internal/core/reading/assemble.go | 4 +- .../core/reading/ingest_comparative_test.go | 32 +++++++++++ internal/core/scribe/ingest_test.go | 21 +++++-- internal/surface/cli/capture_admit_test.go | 10 +++- 12 files changed, 203 insertions(+), 30 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index acc91b5b5..0748df7b0 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -194,7 +194,13 @@ second** (itd-2609020625400194, spc-2609020626040342). No disposition in any state, and no admission, is written for a widening item until a committed comparative run names the item's run; a comparative run committed with an empty item set, the position not exercised, satisfies this as a characterising run -does. The refusal names the run it is waiting on. It is one gate in the one +does. A committed run is the pair the channel's ingest leaves in the run's +directory, its manifest and its run record, agreeing on the run id, the position +and the candidate join; a run record naming the item's run without that +agreement, such as a marker written by hand, is refused by name as a record that +contradicts itself (iss-2609251842111593). Whether git tracks the pair is not +asked, because the gate answers between an ingest and the commit that carries +it. The refusal names the run it is waiting on. It is one gate in the one disposition writer every verb routes through, so the disposition verb, the admission verb and a scribe's ingest all refuse the same way. The other positions are answered with no comparative run anywhere. diff --git a/commands/capture.md b/commands/capture.md index 77075d3b7..a7961b82f 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -500,7 +500,8 @@ run. The refusal names the run and says what it is waiting for: the comparative reading over that run, ingested through `/abcd:reading`. A comparative run committed with an empty item set, the position not exercised, satisfies it too. Every other position is answered with no comparative run anywhere. Relay the -refusal; do not write the record by hand to get past it. +refusal; do not write the record by hand to get past it: a run record without the +manifest the ingest writes beside it, or disagreeing with it, is refused by name. ## Admit a widening proposal diff --git a/evals/coldreading_rehearsal_test.go b/evals/coldreading_rehearsal_test.go index 693c7af95..1d7c461d1 100644 --- a/evals/coldreading_rehearsal_test.go +++ b/evals/coldreading_rehearsal_test.go @@ -1391,9 +1391,9 @@ func disposition(t *testing.T, f fixture, item string, args ...string) dispositi // which is right and makes an item a single-use subject. Assembling another run // is what the operator would do, and it is cheap on this corpus. // placeComparativeRunRecord writes the committed run record of a comparative run -// over wideningRun into the durable run directory: the commit marker -// capture's ordering gate reads (ComparativeRunFor), carrying the -// candidate-join subset it decodes. +// over wideningRun into the durable run directory, with the manifest beside it: +// the commit marker capture's ordering gate reads (ComparativeRunFor), carrying +// the candidate-join subset it decodes. func placeComparativeRunRecord(t *testing.T, f fixture, wideningRun string) { t.Helper() const compRun = "rdg-2609259999999999" @@ -1401,9 +1401,14 @@ func placeComparativeRunRecord(t *testing.T, f fixture, wideningRun string) { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "run.json"), []byte(`{"run_id":"`+compRun+ - `","position":"comparative","candidate_run":"`+wideningRun+`"}`), 0o644); err != nil { - t.Fatal(err) + // The channel's pair: the manifest it promotes and the run record after it, + // agreeing on the run and the candidate join, which is what the gate holds a + // committed run to (iss-2609251842111593). + head := []byte(`{"run_id":"` + compRun + `","position":"comparative","candidate_run":"` + wideningRun + `"}`) + for _, name := range []string{"manifest.json", "run.json"} { + if err := os.WriteFile(filepath.Join(dir, name), head, 0o644); err != nil { + t.Fatal(err) + } } } diff --git a/internal/core/capture/admit_test.go b/internal/core/capture/admit_test.go index e1c52fa1d..59a140023 100644 --- a/internal/core/capture/admit_test.go +++ b/internal/core/capture/admit_test.go @@ -207,8 +207,10 @@ func TestAdmitRefusesBeforeTheComparativeRun(t *testing.T) { // gate exactly as a characterising run does. func TestAdmitProceedsOnAnEmptyComparativeRun(t *testing.T) { repo, ir, item := readingFixture(t, issueschema.PositionWidening) - writeFile(t, filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), "rdg-2608300000000003", issueschema.RunRecordFileName), - `{"run_id":"rdg-2608300000000003","position":"comparative","candidate_run":"`+fixtureRun+`","candidates":1,"exercised":false,"records":[]}`) + dir := filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), "rdg-2608300000000003") + empty := `{"run_id":"rdg-2608300000000003","position":"comparative","candidate_run":"` + fixtureRun + `","candidates":1,"exercised":false,"records":[]}` + writeFile(t, filepath.Join(dir, issueschema.RunManifestFileName), empty) + writeFile(t, filepath.Join(dir, issueschema.RunRecordFileName), empty) if _, err := Admit(AdmitRequest{RepoRoot: repo, IssuesRoot: ir, Item: item, Grounds: admitGround}); err != nil { t.Fatalf("Admit after an empty comparative run: %v", err) } diff --git a/internal/core/capture/itemfate.go b/internal/core/capture/itemfate.go index 587b34748..d6d67609d 100644 --- a/internal/core/capture/itemfate.go +++ b/internal/core/capture/itemfate.go @@ -138,6 +138,18 @@ func admissionsNaming(issuesRoot, run, item string) ([]string, error) { // run's commit marker lives: a directory holding a parked manifest and no run // record is a run that never happened, and it answers nothing here. // +// A committed run is the channel's PAIR, not a run record alone +// (iss-2609251842111593). The ingest promotes the run's manifest into its +// durable directory before it writes the run record, and both carry the run id +// and the candidate join, so a run record that names this widening run is held +// to them: its run_id names its own directory, and the manifest beside it names +// the same run, the comparative position and the same candidate_run. A record +// that names the widening run and fails either is refused by name, as a record +// contradicting itself, never read as "no run yet" — the id-less, manifest-less +// two-key marker a session could write by hand is exactly that record. Whether +// the pair is tracked by git is not asked: the gate answers between an ingest +// and the commit that carries it, which is the order the channel is used in. +// // The LOWEST match rather than any match, so two comparative runs over one // widening run — a legitimate state, since a second comparative run before any // disposition is a second run — resolve to one answer that does not depend on @@ -171,7 +183,7 @@ func ComparativeRunFor(repoRoot, run string) (string, error) { if err := refuseSymlinkedDir(dir); err != nil { return "", err } - head, ok, err := readRunHead(filepath.Join(dir, issueschema.RunRecordFileName)) + head, ok, err := readRunHead(filepath.Join(dir, issueschema.RunRecordFileName), issueschema.RecordReadLimit) if err != nil { return "", err } @@ -179,12 +191,42 @@ func ComparativeRunFor(repoRoot, run string) (string, error) { continue } if head.Position == PositionComparative && head.CandidateRun == run { + if err := requireChannelPair(dir, name, head); err != nil { + return "", err + } return name, nil } } return "", nil } +// requireChannelPair holds a comparative run record that names a widening run +// to the shape the channel's ingest leaves: its run_id is its directory's name, +// and the manifest beside it agrees on the run, the position and the candidate +// join. A missing or disagreeing half is a fault named with the directory, so a +// hand-placed marker is refused out loud rather than opening the gate. +func requireChannelPair(dir, name string, head issueschema.RunHead) error { + record := filepath.Join(dir, issueschema.RunRecordFileName) + if head.RunID != name { + return fmt.Errorf("%w: the comparative run record %s declares run_id %q but is filed under %s; the channel writes a run's record into its own directory, so this record contradicts itself and does not characterise %s", + ErrInvariantViolation, record, head.RunID, name, head.CandidateRun) + } + manifest := filepath.Join(dir, issueschema.RunManifestFileName) + m, ok, err := readRunHead(manifest, issueschema.RunArtefactReadLimit) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%w: the comparative run %s has a run record naming %s but no %s beside it; the channel's ingest writes the manifest before the record, so this run was not committed by it and does not characterise %s", + ErrInvariantViolation, name, head.CandidateRun, issueschema.RunManifestFileName, head.CandidateRun) + } + if m.RunID != head.RunID || m.Position != head.Position || m.CandidateRun != head.CandidateRun { + return fmt.Errorf("%w: the comparative run %s's manifest (run_id %q, position %q, candidate_run %q) disagrees with its run record (run_id %q, position %q, candidate_run %q), so it does not characterise %s", + ErrInvariantViolation, name, m.RunID, m.Position, m.CandidateRun, head.RunID, head.Position, head.CandidateRun, head.CandidateRun) + } + return nil +} + // PositionComparative is the comparative position's token, as the run record // spells it. It is stated here rather than imported because core/reading imports // this package and not the other way round; issueschema.ReadingPositions is the @@ -193,8 +235,8 @@ const PositionComparative = "comparative" // readRunHead decodes one run record's candidate-join subset. A missing file is // "not a committed run", not a fault: the marker's absence is the state. -func readRunHead(path string) (issueschema.RunHead, bool, error) { - raw, err := fsutil.ReadGuarded(path, issueschema.RecordReadLimit) +func readRunHead(path string, limit int64) (issueschema.RunHead, bool, error) { + raw, err := fsutil.ReadGuarded(path, limit) if err != nil { if os.IsNotExist(err) { return issueschema.RunHead{}, false, nil diff --git a/internal/core/capture/itemfate_test.go b/internal/core/capture/itemfate_test.go index 289b030a2..f2f55957d 100644 --- a/internal/core/capture/itemfate_test.go +++ b/internal/core/capture/itemfate_test.go @@ -1,6 +1,7 @@ package capture import ( + "errors" "os" "path/filepath" "slices" @@ -141,10 +142,8 @@ func TestComparativeRunForNamesTheLowestMatch(t *testing.T) { // Two comparative runs over the same widening run, plus a comparative run // over another and a widening run of its own. The lowest match is what comes // back, so the answer does not depend on directory order. - writeFile(t, filepath.Join(runs, "rdg-2608300000000009", issueschema.RunRecordFileName), - `{"run_id":"rdg-2608300000000009","position":"comparative","candidate_run":"`+widening+`"}`) - writeFile(t, filepath.Join(runs, "rdg-2608300000000005", issueschema.RunRecordFileName), - `{"run_id":"rdg-2608300000000005","position":"comparative","candidate_run":"`+widening+`"}`) + writeComparativeRun(t, repo, "rdg-2608300000000009", widening) + writeComparativeRun(t, repo, "rdg-2608300000000005", widening) writeFile(t, filepath.Join(runs, "rdg-2608300000000007", issueschema.RunRecordFileName), `{"run_id":"rdg-2608300000000007","position":"comparative","candidate_run":"rdg-2608300000000002"}`) writeFile(t, filepath.Join(runs, widening, issueschema.RunRecordFileName), @@ -208,3 +207,51 @@ func TestIngestReadingCommitsARunWithNoItems(t *testing.T) { t.Errorf("the result names run %q", res.Run) } } + +// TestComparativeRunForRefusesAMarkerTheChannelDidNotWrite is +// iss-2609251842111593: the gate read "committed comparative run" as any +// run.json decoding to position comparative with a candidate_run, so an id-less, +// manifest-less two-key marker written by hand opened it. The channel's ingest +// writes the run's manifest into the run directory before the run record, and +// both carry the run id and the candidate join, so a run that satisfies the gate +// is one whose record names its own directory and whose manifest agrees with it. +// A marker that names the widening run and fails either check is a record +// contradicting itself, refused by name rather than read as "no run yet". +func TestComparativeRunForRefusesAMarkerTheChannelDidNotWrite(t *testing.T) { + const widening, comp = "rdg-2608300000000001", "rdg-2608300000000005" + record := `{"run_id":"` + comp + `","position":"comparative","candidate_run":"` + widening + `"}` + manifest := `{"run_id":"` + comp + `","position":"comparative","candidate_run":"` + widening + `"}` + for _, tc := range []struct { + name, record, manifest string + }{ + {"an id-less two-key marker", `{"position":"comparative","candidate_run":"` + widening + `"}`, manifest}, + {"a run id naming another directory", `{"run_id":"rdg-2608300000000006","position":"comparative","candidate_run":"` + widening + `"}`, manifest}, + {"no manifest beside the record", record, ""}, + {"a manifest naming another candidate", record, `{"run_id":"` + comp + `","position":"comparative","candidate_run":"rdg-2608300000000002"}`}, + {"a manifest at another position", record, `{"run_id":"` + comp + `","position":"widening","candidate_run":"` + widening + `"}`}, + {"a manifest naming another run", record, `{"run_id":"rdg-2608300000000006","position":"comparative","candidate_run":"` + widening + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + repo, _ := ledger(t) + dir := filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), comp) + writeFile(t, filepath.Join(dir, issueschema.RunRecordFileName), tc.record) + if tc.manifest != "" { + writeFile(t, filepath.Join(dir, issueschema.RunManifestFileName), tc.manifest) + } + got, err := ComparativeRunFor(repo, widening) + if got != "" { + t.Fatalf("a marker the channel did not write satisfied the gate as %q", got) + } + if !errors.Is(err, ErrInvariantViolation) || !strings.Contains(err.Error(), comp) { + t.Fatalf("err = %v, want ErrInvariantViolation naming %s", err, comp) + } + }) + } + + // The channel's own pair satisfies it. + repo, _ := ledger(t) + writeComparativeRun(t, repo, comp, widening) + if got, err := ComparativeRunFor(repo, widening); err != nil || got != comp { + t.Fatalf("ComparativeRunFor over the channel's pair = %q, %v; want %s", got, err, comp) + } +} diff --git a/internal/core/capture/reading_test.go b/internal/core/capture/reading_test.go index 43ae82720..3ff8e324b 100644 --- a/internal/core/capture/reading_test.go +++ b/internal/core/capture/reading_test.go @@ -555,8 +555,19 @@ const fixtureRun = "rdg-2608300000000001" // because the gate only READS the channel's output (spc-2609020626040342, Out). func commitComparativeRun(t *testing.T, repo, compRun, candidateRun string) { t.Helper() - writeFile(t, filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), compRun, issueschema.RunRecordFileName), - `{"run_id":"`+compRun+`","position":"comparative","candidate_run":"`+candidateRun+`"}`) + writeComparativeRun(t, repo, compRun, candidateRun) +} + +// writeComparativeRun writes the pair the comparative channel's ingest leaves in +// a committed run's directory — the manifest it promotes and the run record +// after it — agreeing on the run id, the position and the candidate join, which +// is what ComparativeRunFor holds a committed run to (iss-2609251842111593). +func writeComparativeRun(t *testing.T, repo, compRun, candidateRun string) { + t.Helper() + dir := filepath.Join(repo, filepath.FromSlash(issueschema.ReadingsRecordDir), compRun) + head := `{"run_id":"` + compRun + `","position":"comparative","candidate_run":"` + candidateRun + `"}` + writeFile(t, filepath.Join(dir, issueschema.RunManifestFileName), head) + writeFile(t, filepath.Join(dir, issueschema.RunRecordFileName), head) } // dispositionFiles lists every file under the dispositions tree, for a test that diff --git a/internal/core/issueschema/ledgerdirs.go b/internal/core/issueschema/ledgerdirs.go index 1d32a7190..833914589 100644 --- a/internal/core/issueschema/ledgerdirs.go +++ b/internal/core/issueschema/ledgerdirs.go @@ -45,6 +45,20 @@ const ReadingsRecordDir = ".abcd/development/readings" // records are the next ingest sweep's to roll back. const RunRecordFileName = "run.json" +// RunManifestFileName is the manifest a run was assembled from. The channel's +// ingest promotes it into the durable run directory BEFORE it writes the run +// record, so a committed run holds both, and the two agree on the run id, the +// position and the candidate join. The ordering gate reads that agreement +// (capture.ComparativeRunFor), which is why the name lives here rather than in +// core/reading alone. +const RunManifestFileName = "manifest.json" + +// RunArtefactReadLimit is the ceiling a run's own artefacts (its manifest, its +// bundle) are read under, by the reading channel and by the gate that reads a +// committed run's manifest back. A manifest carries the run's whole item list, +// so it is bounded by the channel's ceiling rather than a single record's. +const RunArtefactReadLimit = 4 << 20 + // RunHead is the strictly decoded subset of a committed run record that answers // one question: which widening run did this comparative run characterise? // diff --git a/internal/core/reading/assemble.go b/internal/core/reading/assemble.go index 9658ded3f..2d94b3fac 100644 --- a/internal/core/reading/assemble.go +++ b/internal/core/reading/assemble.go @@ -278,7 +278,7 @@ func estimateTokens(b int) int { // stays with the auditor. const ( BundleFileName = "bundle.json" - ManifestFileName = "manifest.json" + ManifestFileName = issueschema.RunManifestFileName ) // DefaultRunDir is the local-tier parent an unnamed run is parked under. @@ -287,7 +287,7 @@ const DefaultRunDir = ".abcd/.work.local/scratch/reading-runs" // MaxFileBytes bounds one admitted file. A file past the cap is a refusal, not // a truncation: a silently shortened item would be an assembled input no re-run // could reproduce from the manifest's hash. -const MaxFileBytes = 4 << 20 +const MaxFileBytes = issueschema.RunArtefactReadLimit // LintConfigPath is the record-lint configuration the record scan reads its // stores from. Enumeration comes from that scan and nowhere else: there is one diff --git a/internal/core/reading/ingest_comparative_test.go b/internal/core/reading/ingest_comparative_test.go index 842e29cce..a8f767db2 100644 --- a/internal/core/reading/ingest_comparative_test.go +++ b/internal/core/reading/ingest_comparative_test.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/intentdriven/abcd/internal/core/capture" ) // ac-7. TestIngestRefusesACandidateOutsideTheRun: a comparative item naming a @@ -404,3 +406,33 @@ func TestIngestValidatesTheBytesTheFrontDoorRead(t *testing.T) { t.Fatalf("res %+v err %v", res, err) } } + +// TestTheChannelsCommittedComparativeRunSatisfiesTheGate joins the writer to the +// reader it feeds (iss-2609251842111593): the ordering gate holds a committed +// comparative run to the pair this ingest leaves — the manifest it promotes and +// the run record after it, agreeing on the run and the candidate join — so the +// channel's own output, exercised or not, must be what satisfies it. A fixture +// that hand-writes the pair proves the gate's rule; this proves the channel +// meets it. +func TestTheChannelsCommittedComparativeRunSatisfiesTheGate(t *testing.T) { + t.Run("exercised", func(t *testing.T) { + f := newIngestFixture(t, PositionComparative) + f.mustIngest(f.payload(2)) + if got, err := capture.ComparativeRunFor(f.root, fixtureIngestCandidateRun); err != nil || got != f.runID { + t.Fatalf("ComparativeRunFor after the channel's ingest = %q, %v; want %s", got, err, f.runID) + } + }) + t.Run("not exercised", func(t *testing.T) { + f := newIngestFixture(t, PositionComparative) + notExercised := false + f.parkComparative(f.runID, fixtureIngestCandidateRun, 1, ¬Exercised, nil) + doc := f.payload(0) + doc["manifest_sha256"] = f.manifestHashOf(f.runID) + if _, err := f.ingest(doc); err != nil { + t.Fatal(err) + } + if got, err := capture.ComparativeRunFor(f.root, fixtureIngestCandidateRun); err != nil || got != f.runID { + t.Fatalf("ComparativeRunFor after the channel's empty ingest = %q, %v; want %s", got, err, f.runID) + } + }) +} diff --git a/internal/core/scribe/ingest_test.go b/internal/core/scribe/ingest_test.go index 8b3a2a9a7..f1a4ca073 100644 --- a/internal/core/scribe/ingest_test.go +++ b/internal/core/scribe/ingest_test.go @@ -446,8 +446,7 @@ func TestScribeIngestWritesAdmissionsAndSurprises(t *testing.T) { "{0}: admit it — "+groundA+".\nSurprise at {0}: "+surprise+".\n") // Characterise the widening run, so the ordering gate lets the admission // through. - writeFile(t, s.repo, filepath.Join(issueschema.ReadingsRecordDir, "rdg-2609250000000009", issueschema.RunRecordFileName), - `{"run_id":"rdg-2609250000000009","position":"comparative","candidate_run":"`+fixtureRun+`"}`) + characterise(t, s.repo) o := s.out() o.Admissions = []OutAdmission{{Item: s.items[0], Grounds: groundA}} o.Surprises = []OutSurprise{{OccasionedBy: s.items[0], Text: surprise}} @@ -513,8 +512,7 @@ func TestScribeIngestHoldsTheStateToTheItemsLine(t *testing.T) { func TestScribeIngestHoldsAnAdmissionToTheItemsLine(t *testing.T) { s := assembleSession(t, issueschema.PositionWidening, 2, "{0}: declined — "+groundA+".\n{1}: admit it — "+groundA+".\n") - writeFile(t, s.repo, filepath.Join(issueschema.ReadingsRecordDir, "rdg-2609250000000009", issueschema.RunRecordFileName), - `{"run_id":"rdg-2609250000000009","position":"comparative","candidate_run":"`+fixtureRun+`"}`) + characterise(t, s.repo) before := s.ledger(t) o := s.out() o.Admissions = []OutAdmission{{Item: s.items[0], Grounds: groundA}, {Item: s.items[1], Grounds: groundA}} @@ -740,8 +738,7 @@ func TestScribeIngestHoldsTheStateToTheItemsPartOfALine(t *testing.T) { // An admission is held by the same rule. s3 := assembleSession(t, issueschema.PositionWidening, 2, "{0}: declined — "+groundA+"."+termLF+"{1}: admit it — "+groundA+" (not {0})."+termLF) - writeFile(t, s3.repo, filepath.Join(issueschema.ReadingsRecordDir, "rdg-2609250000000009", issueschema.RunRecordFileName), - `{"run_id":"rdg-2609250000000009","position":"comparative","candidate_run":"`+fixtureRun+`"}`) + characterise(t, s3.repo) o3 := s3.out() o3.Admissions = []OutAdmission{{Item: s3.items[0], Grounds: groundA}, {Item: s3.items[1], Grounds: groundA}} if _, err := s3.ingest(t, s3.write(t, o3)); err == nil || !strings.Contains(err.Error(), "admission") || @@ -789,3 +786,15 @@ func TestScribeIngestRefusesASymlinkedReadingsOrRunDir(t *testing.T) { }) } } + +// characterise writes the pair the comparative channel's ingest leaves for a +// committed comparative run over fixtureRun — the manifest and the run record, +// agreeing on the run and the candidate join — which is what the ordering gate +// reads (capture.ComparativeRunFor; iss-2609251842111593). +func characterise(t *testing.T, repo string) { + t.Helper() + const comp = "rdg-2609250000000009" + head := `{"run_id":"` + comp + `","position":"comparative","candidate_run":"` + fixtureRun + `"}` + writeFile(t, repo, filepath.Join(issueschema.ReadingsRecordDir, comp, issueschema.RunManifestFileName), head) + writeFile(t, repo, filepath.Join(issueschema.ReadingsRecordDir, comp, issueschema.RunRecordFileName), head) +} diff --git a/internal/surface/cli/capture_admit_test.go b/internal/surface/cli/capture_admit_test.go index ec13b2a6c..01e51614d 100644 --- a/internal/surface/cli/capture_admit_test.go +++ b/internal/surface/cli/capture_admit_test.go @@ -35,9 +35,13 @@ func writeWideningFixture(t *testing.T, repo string, characterised bool) { if err := os.MkdirAll(comp, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(comp, "run.json"), - []byte(`{"run_id":"rdg-2608300000000009","position":"comparative","candidate_run":"`+admitRun+`"}`), 0o644); err != nil { - t.Fatal(err) + // The channel's pair: the manifest it promotes and the run record after it, + // agreeing on the run and the candidate join (iss-2609251842111593). + head := []byte(`{"run_id":"rdg-2608300000000009","position":"comparative","candidate_run":"` + admitRun + `"}`) + for _, name := range []string{"manifest.json", "run.json"} { + if err := os.WriteFile(filepath.Join(comp, name), head, 0o644); err != nil { + t.Fatal(err) + } } } From 23eb3823a656e0c3497ffdfae3849258a7dc20e2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:20:43 +0100 Subject: [PATCH 28/78] =?UTF-8?q?chore:=20resolve=20iss-2609251842111593?= =?UTF-8?q?=20=E2=80=94=20the=20gate=20reads=20the=20channel's=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609251842111593 Assisted-by: Claude:claude-opus-5-5 --- ...mission-ordering-gate-reads-committed-as-a-run-json.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md (54%) diff --git a/.abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md b/.abcd/work/issues/resolved/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md similarity index 54% rename from .abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md rename to .abcd/work/issues/resolved/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md index 85fa07d64..de12e40ec 100644 --- a/.abcd/work/issues/open/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.md +++ b/.abcd/work/issues/resolved/iss-2609251842111593-the-admission-ordering-gate-reads-committed-as-a-run-json.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/capture/itemfate.go" +resolution: "the ordering gate holds a comparative run record naming the widening run to the channel's pair: run_id names its directory and the manifest beside it agrees on run, position and candidate_run; a hand-placed marker without that is refused by name" +impact: fix +resolved_by: + commit: "819709856" --- The admission ordering gate reads 'committed' as a run.json decoding to position comparative with a candidate_run, with no run_id/directory, manifest, item or git check (internal/core/capture/itemfate.go:145-186), so an untracked, id-less marker opens the gate; this matches the spec's Approach but not its line that no mutable file anywhere records the outcome (review-admission 2). + +## Grounds + +- pursued: an id-less or manifest-less comparative marker no longer opens the widening gate while the channel's own ingest still does; TestComparativeRunForRefusesAMarkerTheChannelDidNotWrite or TestTheChannelsCommittedComparativeRunSatisfiesTheGate failing would show it wrong From 91c12634ed4132a1ea30804c06c9fed6cdffacf2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:21:23 +0100 Subject: [PATCH 29/78] docs(capture): name the rebased-series case of a whole reframe write The whole reframe write reads back to the previous distinct state along first parents, so a rewrite that lands as several commits on that line, a rebased branch among them, is recorded as its last step alone (two commits moving the construal then the glossary give changed=[glossary]), where a squash or a --no-ff merge of the same rewrite gives [construal glossary]. The command page and the brief's capture chapter claimed only the squash equivalence; both now name the series case and the route that records such a rewrite whole (--open before the first commit, --complete after the last). The record offered a line on the page or a spec ruling; no ruling is owed on it in the rulings list, so the page states the behaviour spc-2609020626048705's first-differing-state rule already produces, and a test pins it. Refs: iss-2609261325441711 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/06-capture.md | 8 ++++-- commands/capture.md | 7 ++++- internal/core/capture/reframe_test.go | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/06-capture.md b/.abcd/development/brief/04-surfaces/06-capture.md index 0748df7b0..3e2cbad1f 100644 --- a/.abcd/development/brief/04-surfaces/06-capture.md +++ b/.abcd/development/brief/04-surfaces/06-capture.md @@ -237,8 +237,12 @@ any surface. The verb reads the surfaces at `HEAD`, in the working tree and along their history, so the operator supplies no hash. Written after the rewrite's commit it is one write, against the previous distinct state along first parents, so a rewrite a merge brought in is recorded as a squash of the -same branch would record it, whatever the commits' timestamps; written before -it, a first half records the +same branch would record it, whatever the commits' timestamps. A rewrite that +lands as several commits on the first-parent line, a rebased branch among them, +is recorded by that one write as its last step alone, because the previous +distinct state is the one before that step (iss-2609261325441711); the open and +complete pair below records such a rewrite whole. Written before +the rewrite's commit, a first half records the before fingerprints and a second write finishes it once the rewrite is committed, walking back across as many commits as the rewrite took, merges included. Every render names the half it wrote. The occasion diff --git a/commands/capture.md b/commands/capture.md index a7961b82f..0070a4000 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -595,7 +595,12 @@ A reframe is written in one of three halves, and every render names which: not. It walks the surfaces' history along first parents to the previous distinct committed state and writes both halves at once, so a rewrite a merge brought in is recorded against the state the merge's first parent held, as a - squash of the same branch would be, whatever the commits' timestamps. + squash of the same branch would be, whatever the commits' timestamps. A + rewrite that lands as several commits on the first-parent line, a rebased + branch among them, is different: the previous distinct state is the one + before its last commit, so the whole write records that step alone. To record + such a rewrite whole, open the record before its first commit and complete it + after its last. - **Open**, before the rewrite is committed (`--open`). The before fingerprints are `HEAD`'s and the after half is absent; the render names the completion. Only one record may be open at a time. diff --git a/internal/core/capture/reframe_test.go b/internal/core/capture/reframe_test.go index 59325986e..4b3c836a9 100644 --- a/internal/core/capture/reframe_test.go +++ b/internal/core/capture/reframe_test.go @@ -441,6 +441,33 @@ func TestReframeWholeWriteAcrossAMergeIsTopological(t *testing.T) { } } +// TestReframeWholeWriteOfALinearSeriesRecordsItsLastStep pins what the command +// page states about a rewrite that lands as a series of commits on the first +// parent line, a rebased branch among them (iss-2609261325441711): the whole +// write reads back to the previous DISTINCT state, which is the state before the +// series' last step, so it records that step alone. Two commits that move the +// construal and then the glossary give changed=[glossary] with before at the +// post-construal state, where a squash or a --no-ff merge of the same rewrite +// gives [construal glossary]. The route that records the whole series is --open +// before its first commit and --complete after its last +// (TestCompleteCrossesATwoCommitRewrite). +func TestReframeWholeWriteOfALinearSeriesRecordsItsLastStep(t *testing.T) { + r := reframeFixture(t) + rewriteConstrual(r, "First step of a rebased rewrite.") + mid := frameAt(t, r) + r.Write(fxGlossary+"/core/term.md", "# Term\n\nSecond step of a rebased rewrite.\n") + r.Commit("rewrite a term") + after := frameAt(t, r) + + res, err := Reframe(reframeReq(r, fxItem)) + if err != nil { + t.Fatalf("Reframe: %v", err) + } + if res.Before != mid || res.After != after || res.Commits != 1 || !slices.Equal(res.Changed, []string{"glossary"}) { + t.Fatalf("result = %+v\nwant before at the post-construal state, [glossary] across 1 commit", res) + } +} + func TestReframeRefusesUncommittedChangesWithoutOpen(t *testing.T) { r := reframeFixture(t) rewriteConstrual(r, "A committed rewrite.") From c36ba15035aa54aa065785c90715af10008e2b5f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:21:25 +0100 Subject: [PATCH 30/78] =?UTF-8?q?chore:=20resolve=20iss-2609261325441711?= =?UTF-8?q?=20=E2=80=94=20the=20rebased-series=20reframe=20case=20is=20nam?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261325441711 Assisted-by: Claude:claude-opus-5-5 --- ...e-reframe-s-whole-write-reads-a-multi-commit-rebase.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md (64%) diff --git a/.abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md b/.abcd/work/issues/resolved/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md similarity index 64% rename from .abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md rename to .abcd/work/issues/resolved/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md index f18c6c0e3..07bfdb7ba 100644 --- a/.abcd/work/issues/open/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md +++ b/.abcd/work/issues/resolved/iss-2609261325441711-capture-reframe-s-whole-write-reads-a-multi-commit-rebase.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: integ3, review2-reframe OBS- origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/reframe.go" +resolution: "commands/capture.md and brief 06-capture.md name the rebased-series case (the whole write records the last step alone) and the open/complete route that records it whole; TestReframeWholeWriteOfALinearSeriesRecordsItsLastStep pins the behaviour" +impact: fix +resolved_by: + commit: "91c12634e" --- capture reframe's whole write reads a multi-commit rebase differently from a --no-ff merge or a squash of the same rewrite: two rebased commits that move the construal and then the glossary give changed=[glossary] with before at the post-construal state, where --no-ff and squash give changed=[construal glossary]. The first-differing-triple rule of spc-2609020626048705 produces it, and commands/capture.md and brief 06-capture.md claim only squash equivalence. Either a line on the page naming the rebase case or a spec ruling on what previous distinct state means across a rebased series; not decided here (review2-reframe OBS-A). + +## Grounds + +- pursued: the page now says what the whole write records for a multi-commit first-parent rewrite, matching the code; the pin test failing, or a spec ruling that redefines the previous distinct state across a rebased series, would show it wrong From 922a2a6a3774715e52d1a4cf8ee485c15cfb7d74 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:25:59 +0100 Subject: [PATCH 31/78] fix(lint): close the privacy backstop's two blind spots over the issue ledger The privacy-hygiene rule caught the raw Windows home `C:\Users\` and missed the escaped spelling the ledger serialiser and a JSON encoder write. Its Windows arm now reads a separator as a run of backslashes at any escaping depth, and the system-root traversal walk reads one escaped separator as one, so `\\` doubled in an escaped path is still the empty segment it is in a raw one. record-lint's harness_leak was rooted at the durable record alone and never read the ledger. It now takes extra_roots, walked for the leak class alone the way links_resolve walks its own, deduplicated against the Roots walk, and the committed configuration arms it over .abcd/work with the reviews tree exempt as links_resolve exempts it. Refs: iss-2608301306580014 Assisted-by: Claude:claude-opus-5-5 --- .abcd/record-lint.json | 9 ++- internal/core/lint/config.go | 4 +- internal/core/lint/harnessleak_test.go | 58 +++++++++++++++++++ internal/core/lint/linksextra.go | 30 ++++++++-- internal/core/lint/lint.go | 7 +++ internal/core/repolint/rule_privacy.go | 52 +++++++++++++---- .../repolint/rule_privacy_mandated_test.go | 35 +++++++++++ 7 files changed, 176 insertions(+), 19 deletions(-) diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index f969f4e45..65f53d8b1 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -235,7 +235,14 @@ }, "harness_leak": { "enabled": true, - "severity": "blocker" + "severity": "blocker", + "extra_roots": [ + ".abcd/work" + ], + "exempt": [ + ".abcd/work/reviews/*", + ".abcd/work/reviews/*/*" + ] }, "directory_coverage": { "enabled": true, diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 6a81f4519..599ac852c 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -98,7 +98,9 @@ type RuleConfig struct { // ExtraRoots are repo-relative trees links_resolve walks for links ALONE, // beyond Roots: the working tier (.abcd/work) holds relative links in the issue // ledger, DECISIONS.md and CONTEXT.md, and adding it to Roots would arm every - // content rule there too (iss-2608230752354927). + // content rule there too (iss-2608230752354927). harness_leak reads its own + // ExtraRoots the same way, for the leak class alone: the ledger is committed + // free text outside the record's Roots (iss-2608301306580014). ExtraRoots []string `json:"extra_roots"` // IntentsDir is the intents subdirectory (relative to a root) read by the // intent-tree rules, intent_lifecycle and intent_impact_valid. Rules that name diff --git a/internal/core/lint/harnessleak_test.go b/internal/core/lint/harnessleak_test.go index 70d5f171b..878f323b2 100644 --- a/internal/core/lint/harnessleak_test.go +++ b/internal/core/lint/harnessleak_test.go @@ -127,3 +127,61 @@ func TestHarnessLeakSecondMatchOnALine(t *testing.T) { t.Fatalf("a skipped leftmost candidate hid a real session URL; got %d: %+v", n, fs) } } + +// The issue ledger is committed free text a verb writes from operator input, +// and it sits outside the record's Roots, so a harness_leak rooted at the +// durable record alone never read it (iss-2608301306580014). The rule's own +// extra_roots arm it over the ledger for the leak class alone, and a tree the +// Roots walk already read is not read twice. +func TestHarnessLeakReadsItsExtraRoots(t *testing.T) { + root := t.TempDir() + writeFile(t, root, filepath.Join("docs", "run.md"), "# Run\n\nRecorded at "+synthSessionURL(t, 31)+"\n") + writeFile(t, root, filepath.Join("work", "issues", "resolved", "iss-1-a.md"), + "---\nid: \"iss-1\"\nresolution: \"fixed in the run at "+synthSessionURL(t, 37)+"\"\n---\n\nBody.\n") + writeFile(t, root, filepath.Join("work", "reviews", "r.md"), "# Review\n\nAt "+synthSessionURL(t, 41)+"\n") + + cfg := harnessLeakCfg() + rc := cfg.Rules[ruleHarnessLeak] + rc.ExtraRoots = []string{"work", "docs"} + rc.Exempt = []string{"work/reviews/*"} + cfg.Rules[ruleHarnessLeak] = rc + fs, err := Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + if !hasFinding(fs, filepath.Join("work", "issues", "resolved", "iss-1-a.md"), ruleHarnessLeak, 3) { + t.Errorf("a session URL in a ledger record is not flagged: %+v", fs) + } + if n := countRule(fs, ruleHarnessLeak); n != 2 { + t.Fatalf("want one finding in the ledger and one in docs, the exempt review and the doubly-declared docs "+ + "tree drawing no second one; got %d: %+v", n, fs) + } + + // Without the extra root the ledger is outside the rule, as it was. + fs, err = Lint(harnessLeakCfg(), root) + if err != nil { + t.Fatal(err) + } + if n := countRule(fs, ruleHarnessLeak); n != 1 { + t.Fatalf("want the docs finding alone without extra_roots; got %d: %+v", n, fs) + } +} + +// The repository's own record-lint configuration arms harness_leak over the +// working tier, where the issue ledger lives (iss-2608301306580014). +func TestRecordLintArmsHarnessLeakOverTheLedger(t *testing.T) { + cfg, err := LoadConfig(filepath.Join("..", "..", "..", ".abcd", "record-lint.json")) + if err != nil { + t.Fatal(err) + } + rc, ok := cfg.Rules[ruleHarnessLeak] + if !ok || !rc.Enabled { + t.Fatal("record-lint.json must enable harness_leak") + } + for _, r := range rc.ExtraRoots { + if r == ".abcd/work" { + return + } + } + t.Fatalf("record-lint.json harness_leak extra_roots = %v, want .abcd/work, the tree the issue ledger lives in", rc.ExtraRoots) +} diff --git a/internal/core/lint/linksextra.go b/internal/core/lint/linksextra.go index a67cb4be5..a404b80e6 100644 --- a/internal/core/lint/linksextra.go +++ b/internal/core/lint/linksextra.go @@ -13,20 +13,39 @@ import ( // not exist is misconfiguration, for the reason a missing root is: it would // silently disarm the rule for that tree. func checkLinksExtraRoots(repoRoot string, cfg RuleConfig) ([]Finding, error) { + return walkExtraRoots(repoRoot, "links_resolve", cfg, nil, func(rel, fileAbs string, lines []string) []Finding { + return checkLinks(rel, fileAbs, repoRoot, lines, fenceMask(lines), cfg) + }) +} + +// checkHarnessLeakExtraRoots runs harness_leak over the rule's ExtraRoots, the +// same way: the working tier's issue ledger is committed free text a verb +// writes from operator input, and a rule rooted at the durable record alone +// never read it (iss-2608301306580014). A file the Roots walk already read is +// not read twice, so a tree both declare draws one finding per leak. +func checkHarnessLeakExtraRoots(repoRoot string, cfg RuleConfig, scanned map[string]bool) ([]Finding, error) { + return walkExtraRoots(repoRoot, ruleHarnessLeak, cfg, scanned, func(rel, _ string, lines []string) []Finding { + return checkHarnessLeak(rel, lines, fenceMask(lines), cfg) + }) +} + +// walkExtraRoots reads every markdown file under a rule's ExtraRoots, except +// one skip names or an Exempt glob matches, and hands each to check. +func walkExtraRoots(repoRoot, ruleID string, cfg RuleConfig, skip map[string]bool, check func(rel, fileAbs string, lines []string) []Finding) ([]Finding, error) { var out []Finding for _, root := range cfg.ExtraRoots { if err := containedRepoPath(root); err != nil { - return nil, &configError{"links_resolve extra_roots entry " + quote(root) + " " + err.Error() + + return nil, &configError{ruleID + " extra_roots entry " + quote(root) + " " + err.Error() + "; the lint reads only inside the repository"} } rootAbs := filepath.Join(repoRoot, filepath.FromSlash(root)) if err := resolvedInsideRoot(repoRoot, rootAbs); err != nil { - return nil, &configError{"links_resolve extra_roots entry " + quote(root) + " " + err.Error() + + return nil, &configError{ruleID + " extra_roots entry " + quote(root) + " " + err.Error() + "; the lint reads only inside the repository"} } if _, err := os.Stat(rootAbs); err != nil { if os.IsNotExist(err) { - return nil, &configError{"links_resolve extra_roots entry " + quote(root) + + return nil, &configError{ruleID + " extra_roots entry " + quote(root) + " does not exist; a configured tree that does not resolve silently disarms the rule for it"} } return nil, err @@ -37,15 +56,14 @@ func checkLinksExtraRoots(repoRoot string, cfg RuleConfig) ([]Finding, error) { } for _, fileAbs := range files { rel := repoRel(repoRoot, fileAbs) - if matchesGlob(cfg.Exempt, filepath.ToSlash(rel)) { + if skip[fileAbs] || matchesGlob(cfg.Exempt, filepath.ToSlash(rel)) { continue } content, err := readRepoAbs(repoRoot, fileAbs, maxRepoFileBytes) if err != nil { return nil, err } - lines := strings.Split(string(content), "\n") - out = append(out, checkLinks(rel, fileAbs, repoRoot, lines, fenceMask(lines), cfg)...) + out = append(out, check(rel, fileAbs, strings.Split(string(content), "\n"))...) } } return out, nil diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 479ad96aa..b228a09dd 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -420,6 +420,13 @@ func LintAt(cfg Config, repoRoot string, now time.Time) ([]Finding, error) { } findings = append(findings, lx...) } + if leakOn && len(leakCfg.ExtraRoots) > 0 { + hx, err := checkHarnessLeakExtraRoots(repoRoot, leakCfg, scanned) + if err != nil { + return nil, err + } + findings = append(findings, hx...) + } if len(cfg.NameRoots) > 0 { nf, err := lintNameRoots(cfg, repoRoot, scanned) diff --git a/internal/core/repolint/rule_privacy.go b/internal/core/repolint/rule_privacy.go index 80d7e433c..f798c6df8 100644 --- a/internal/core/repolint/rule_privacy.go +++ b/internal/core/repolint/rule_privacy.go @@ -69,7 +69,15 @@ var ( // the Windows arm is case-folded: NTFS is case-insensitive and `c:\users\bob` // is a common spelling (Python os.path.normcase lowercases the whole path), // while folding the POSIX arm would flag ordinary API-route text ("/users/me"). - absPathRe = regexp.MustCompile(`(?:/Users/|/home/)[A-Za-z0-9._-]+|(?i:[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)`) + // A Windows separator is a RUN of backslashes: the ledger serialiser escapes + // a backslash inside a quoted scalar and a JSON encoder doubles it again, so + // the spelling the tool itself commits is `C:\\Users\\`, and a + // single-backslash arm caught the shape a human types while missing the one + // the tool produces (iss-2608301306580014). + absPathRe = regexp.MustCompile(`(?:/Users/|/home/)[A-Za-z0-9._-]+|(?i:[A-Za-z]:\\+Users\\+[A-Za-z0-9._-]+)`) + // windowsUsersRootRe is the Windows users root inside a match, at any + // escaping depth; its second group is one escaped separator. + windowsUsersRootRe = regexp.MustCompile(`(?i):(\\+)users(\\+)`) ) func (privacyHygiene) Meta() RuleMeta { @@ -309,7 +317,7 @@ func hasAbsHomePath(line string) bool { // is not a home path, and this rule detects home paths; the committing // user's OWN name there is still caught by the scanner's // local_username detector at hard_fail. - if reachedNameViaTraversal(line, loc[1], isWindowsPath(m)) { + if reachedNameViaTraversal(line, loc[1], windowsSeparatorWidth(m)) { return true } continue @@ -378,11 +386,28 @@ func isPersonaHomeSegment(seg string) bool { // exempt the mixed spelling wholesale. A POSIX path stays slash-only, because a // backslash after one is an escape (the two bytes of "/Users/Shared\n" in a // source string), never a path segment. -func reachedNameViaTraversal(line string, pos int, windows bool) bool { - isSep := func(b byte) bool { return b == '/' || (windows && b == '\\') } +// +// width is the backslash run one Windows separator is spelled with in the +// match (1 raw, 2 escaped once, and so on), or 0 for a POSIX path: an escaped +// path's separator is its whole run, and only a run longer than that holds an +// empty segment (iss-2608301306580014). +func reachedNameViaTraversal(line string, pos int, width int) bool { + sepLen := func(p int) int { + if line[p] == '/' { + return 1 + } + if width == 0 || line[p] != '\\' { + return 0 + } + n := 0 + for p+n < len(line) && n < width && line[p+n] == '\\' { + n++ + } + return n + } traversed := false - for pos < len(line) && isSep(line[pos]) { - i, named := pos+1, false + for pos < len(line) && sepLen(pos) > 0 { + i, named := pos+sepLen(pos), false for i < len(line) && isPathSegmentChar(line[i]) { if line[i] != '.' { named = true @@ -403,10 +428,15 @@ func reachedNameViaTraversal(line string, pos int, windows bool) bool { return false } -// isWindowsPath reports whether the matched path is the Windows spelling -// (`C:\Users\`) rather than a POSIX one. -func isWindowsPath(m string) bool { - return strings.Contains(strings.ToLower(m), `:\users\`) +// windowsSeparatorWidth is the backslash run one separator is spelled with in +// a Windows match (`C:\Users\` is 1, its escaped spelling 2), or 0 +// when the match is a POSIX path. +func windowsSeparatorWidth(m string) int { + sub := windowsUsersRootRe.FindStringSubmatch(m) + if sub == nil { + return 0 + } + return len(sub[2]) } // leadingBoundaryOK reports whether the match at start BEGINS a path rather than @@ -432,7 +462,7 @@ func isPathSegmentChar(b byte) bool { // C:\Users) root rather than /home. func isUsersRoot(m string) bool { l := strings.ToLower(m) - return strings.HasPrefix(l, "/users/") || strings.Contains(l, `:\users\`) + return strings.HasPrefix(l, "/users/") || windowsUsersRootRe.MatchString(m) } // readTrackedFile reads a tracked path safely for scanning, relative to root diff --git a/internal/core/repolint/rule_privacy_mandated_test.go b/internal/core/repolint/rule_privacy_mandated_test.go index 7d55dbc12..38d9abf41 100644 --- a/internal/core/repolint/rule_privacy_mandated_test.go +++ b/internal/core/repolint/rule_privacy_mandated_test.go @@ -142,3 +142,38 @@ func countRulePrivacy(res repolint.Result) int { } return n } + +// The escaped Windows spelling is the shape the tool itself writes +// (iss-2608301306580014): the ledger serialiser escapes a backslash inside a +// double-quoted scalar, and a JSON encoder doubles it again, so a home path +// reaches a committed file as `C:\\Users\\`. The backstop caught the +// spelling a human types and missed the one the tool produces. A separator is a +// RUN of backslashes at any escaping depth, and the persona and system-root +// exemptions read the escaped spelling as they read the raw one. +func TestAC_PrivacyEscapedWindowsHomeIsALeak(t *testing.T) { + user := strings.Join([]string{"j", "doe"}, "") + cases := []struct { + name string + body string + want bool + }{ + {"escaped once", `resolution: "found under C:\\Users\\` + user + `\\notes.md"` + "\n", true}, + {"escaped twice", `"C:\\\\Users\\\\` + user + `\\\\notes.md"` + "\n", true}, + {"escaped lowercase", `path c:\\users\\` + user + "\n", true}, + {"escaped persona", `the fixture lives at C:\\Users\\carol\\notes.md` + "\n", false}, + {"escaped public subtree", `report at C:\\Users\\Public\\report.txt` + "\n", false}, + {"escaped public traversal", `keys at C:\\Users\\Public\\..\\` + user + "\n", true}, + {"escaped public doubled separator", `keys at C:\\Users\\Public\\\\` + user + "\n", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res := newFixtureRepo(t).conforming(). + file("reference/paths.md", c.body). + commit().run() + got := findingFor(res, "privacy-hygiene") != nil + if got != c.want { + t.Fatalf("finding = %v, want %v for %q", got, c.want, c.body) + } + }) + } +} From 06d801fbc664fde483161df43729fd129e62d702 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:26:29 +0100 Subject: [PATCH 32/78] =?UTF-8?q?chore:=20resolve=20iss-2608301306580014?= =?UTF-8?q?=20=E2=80=94=20privacy=20backstop=20reads=20the=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608301306580014 Assisted-by: Claude:claude-opus-5-5 --- ...cy-backstop-has-two-blind-spots-over-the-issue-ledg.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md (78%) diff --git a/.abcd/work/issues/open/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md b/.abcd/work/issues/resolved/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md similarity index 78% rename from .abcd/work/issues/open/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md rename to .abcd/work/issues/resolved/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md index d309effa0..6ca2a7104 100644 --- a/.abcd/work/issues/open/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md +++ b/.abcd/work/issues/resolved/iss-2608301306580014-the-privacy-backstop-has-two-blind-spots-over-the-issue-ledg.md @@ -7,6 +7,10 @@ category: "security" source: "user-observation" found_during: "itd-179-round-3-security" found_at: "internal/core/lint" +resolution: "Both blind spots closed: privacy-hygiene's Windows arm reads a separator as a backslash run at any escaping depth (the escaped C:\\\\Users\\\\ the serialiser writes), and record-lint's harness_leak reads .abcd/work through its own extra_roots. The folded DEL/C1/bidi half was closed at the record-write boundary by iss-2608301206073609; the write-time redactor's Windows arm is iss-2609251639261103's own record." +impact: fix +resolved_by: + commit: "922a2a6a3" --- the privacy backstop has two blind spots over the issue ledger: the escaped Windows home path yamlScalar writes and a harness_leak root that excludes the ledger @@ -46,3 +50,7 @@ Trojan-Source-shaped display concern on committed prose. Pre-existing serialiser contract, unchanged by this branch. Refusal messages use %q, which Go escapes, so an ANSI escape in grounds can never reach a terminal raw through a refusal. + +## Grounds + +- pursued: a committed ledger record carrying an escaped Windows home or a session URL now fails abcd lint or record-lint; either shape passing both gates in a ledger file would show it wrong From 46ae88404e823c352d9374e67ecb8fd348b58f68 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:12:27 +0100 Subject: [PATCH 33/78] fix(scripts): a record already terminal at the base enters nothing ids_entering_closed keyed on a rename's destination alone, so a stale branch whose base had since moved the record resolved/ -> wontfix/, or reslugged it inside resolved/, read the two-dot diff's rename back into place as this branch's resolution: a Resolves: trailer was satisfied by a move the branch never made. An id now enters a terminal folder only when the base does not already hold it in one; the base's listing is the test rather than the rename's source, because a move rewritten past rename detection arrives as a plain add with no source. Such a trailer falls through to RS001's diagnosis. ids_entering_shipped had the same shape for a reslug inside shipped/ and takes the same filter, on the canonical id. Cases: a base-side move resolved/ -> wontfix/, a base-side reslug, the same move rewritten past rename detection, and the shipped/ reslug twin, each watched pass the old gate (a violating fixture passed) and refused by this one. Refs: iss-2609012047551175 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 64 ++++++++++++++++++++++ scripts/check-issue-resolution.sh | 72 ++++++++++++++++++++----- 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index f3b8b8f1a..81ba1ae54 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -359,6 +359,55 @@ git -C "$d" checkout -q work expect_refusal_naming "$d" "RS001 on a stale branch diagnoses a record with a non-ASCII slug" \ "iss-998 already sits in $ISS_DIR/resolved/ at main .*squash of work.*[Rr]ebase onto main" -- commits main HEAD +# --- RS001: entering is from outside a terminal folder (iss-2609012047551175) -- +# +# A stale trailer must not be satisfied by a move the branch did not make. In +# each case the record is terminal at the merge base, the branch carries a +# `Resolves:` for it anyway, and the BASE then moves the record between or +# within terminal folders — so the two-dot diff shows it arriving in a terminal +# folder, back where the branch still has it. Keyed on the destination alone, +# every one of these passed. +stale_trailer_on_terminal_record() { + local d + d="$(newrepo "$1")" + git -C "$d" checkout -q main + resolve_record "$d" + git -C "$d" add -A + git -C "$d" commit -qm "chore: resolve a stale issue" + git -C "$d" checkout -q -B work main + echo "touched" >>"$d/README.md" + git -C "$d" add -A + git -C "$d" commit -qm "fix: something else + +Resolves: iss-999" + git -C "$d" checkout -q main + echo "$d" +} + +d="$(stale_trailer_on_terminal_record rs001-base-moved-to-wontfix)" +git -C "$d" mv "$ISS_DIR/resolved/iss-999-a-fixture.md" "$ISS_DIR/wontfix/iss-999-a-fixture.md" +git -C "$d" commit -qm "chore: reclassify as wontfix" +git -C "$d" checkout -q work +expect fail "$d" "RS001 a base-side move resolved/ -> wontfix/ does not satisfy a stale trailer" -- commits main HEAD + +d="$(stale_trailer_on_terminal_record rs001-base-reslugged)" +git -C "$d" mv "$ISS_DIR/resolved/iss-999-a-fixture.md" "$ISS_DIR/resolved/iss-999-reslugged.md" +git -C "$d" commit -qm "chore: reslug the record" +git -C "$d" checkout -q work +expect fail "$d" "RS001 a base-side reslug inside resolved/ does not satisfy a stale trailer" -- commits main HEAD + +# The same move rewritten past rename detection arrives as a plain add, with no +# rename source to read — which is why the test is the base's listing. +d="$(stale_trailer_on_terminal_record rs001-base-moved-rewritten)" +git -C "$d" mv "$ISS_DIR/resolved/iss-999-a-fixture.md" "$ISS_DIR/wontfix/iss-999-a-fixture.md" +for i in 1 2 3 4 5 6 7 8 9 10; do + echo "Rewritten line $i so git reports the move as a delete plus an add." >>"$d/$ISS_DIR/wontfix/iss-999-a-fixture.md" +done +git -C "$d" add -A +git -C "$d" commit -qm "chore: reclassify and rewrite" +git -C "$d" checkout -q work +expect fail "$d" "RS001 a base-side move rewritten past rename detection does not satisfy a stale trailer" -- commits main HEAD + # --- RS002: a stamp added here must name a reachable commit ------------------ d="$(newrepo rs002-bad)" @@ -1077,6 +1126,21 @@ Delivers: itd-8" expect_refusal_naming "$d" "RS005 with an empty open/ still diagnoses the planned intent" \ "itd-8 .*no spec to close" -- commits main HEAD +# RS001's entering-from-outside fix on the intent store: a reslug inside +# shipped/ on the base's side does not satisfy a stale `Delivers:` +# (iss-2609012047551175's twin). +d="$(newrepo_intents rs005-base-reslugged)" +echo "touched" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing again + +Delivers: itd-6" +git -C "$d" checkout -q main +git -C "$d" mv "$INT_DIR/shipped/itd-6-fixture-6.md" "$INT_DIR/shipped/itd-6-reslugged.md" +git -C "$d" commit -qm "docs: reslug itd-6" +git -C "$d" checkout -q work +expect fail "$d" "RS005 a base-side reslug inside shipped/ does not satisfy a stale trailer" -- commits main HEAD + # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary # case (a trailer whose record stays where it was); each refusal is normalised by diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 8be0b15da..6e976b1c9 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -243,25 +243,59 @@ usage() { # plain add into the terminal folder. Both are honest resolutions and both are # caught here. A record that only LEAVES open/ (a bare delete) enters nothing and # is deliberately absent, so RS001 refuses a trailer that merely deletes. +# +# ENTERING means from outside: a record the base already holds in a terminal +# folder enters nothing, whatever the diff shows (iss-2609012047551175). Keyed on +# the destination alone, a move BETWEEN terminal folders counted — so a stale +# branch whose base had since moved the record resolved/ -> wontfix/, or reslugged +# it inside resolved/, read the two-dot diff's rename back into place as this +# branch's resolution, and a trailer satisfied by a move it did not make passed +# silently. The base's own listing is the test rather than the rename's source, +# because a move rewritten past rename detection arrives as a plain add and has +# no source to read. Such a trailer falls through to RS001's diagnosis instead. ids_entering_closed() { - local base="$1" head="$2" + local base="$1" head="$2" terminal_at_base + # `|| exit 2`: this runs inside the caller's command substitution, where + # errexit is cleared, so a failed listing must end the subshell itself for + # pipefail to carry it out. + terminal_at_base="$(terminal_ids "$base" "$ISSUES_DIR/resolved" "$ISSUES_DIR/wontfix" 'iss-[0-9]+')" || exit 2 git diff --name-status --find-renames "$base".."$head" -- "${STATUS_PATHSPECS[@]}" | while IFS=$'\t' read -r status path dest; do + local landed="" id case "$status" in - R*) - case "$dest" in - "$ISSUES_DIR/resolved/"* | "$ISSUES_DIR/wontfix/"*) basename "$dest" | grep -oE '^iss-[0-9]+' || true ;; - esac - ;; - A) - case "$path" in - "$ISSUES_DIR/resolved/"* | "$ISSUES_DIR/wontfix/"*) basename "$path" | grep -oE '^iss-[0-9]+' || true ;; - esac + R*) landed="$dest" ;; + A) landed="$path" ;; + esac + case "$landed" in + "$ISSUES_DIR/resolved/"* | "$ISSUES_DIR/wontfix/"*) + id="$(basename "$landed" | grep -oE '^iss-[0-9]+' || true)" + [ -n "$id" ] || continue + printf '%s\n' "$terminal_at_base" | grep -qx "$id" && continue + printf '%s\n' "$id" ;; esac done } +# terminal_ids prints, one per line, the id (matched by the ERE in $4, anchored +# at the basename's start) of every record ref holds under the terminal folders +# $2 and $3 ($3 may be empty). The listing is rc-checked: a git failure must not +# read as "nothing was terminal", which would re-open the hole this closes. +terminal_ids() { + local ref="$1" d1="$2" d2="$3" idre="$4" listing rc=0 + if [ -n "$d2" ]; then + listing="$(git ls-tree -r --name-only "$ref" -- "$d1" "$d2" 2>&1)" || rc=$? + else + listing="$(git ls-tree -r --name-only "$ref" -- "$d1" 2>&1)" || rc=$? + fi + if [ "$rc" -ne 0 ]; then + echo "check-issue-resolution: git ls-tree failed at $ref (exit $rc) — refusing rather than reporting a vacuous pass:" >&2 + echo "$listing" >&2 + exit 2 + fi + printf '%s\n' "$listing" | sed 's|.*/||' | { grep -oE "^$idre" || true; } | sort -u +} + # record_path prints the ledger path of iss-N's record at ref — its status # folder is the diagnosis RS001 needs — or nothing when the ref holds none. The # id is matched as a whole basename prefix, so iss-99 never answers for iss-999. @@ -351,19 +385,29 @@ canon_itd() { # shipped/ across the range: a move out of planned/ (a rename, or an add without # rename detection) or a record filed straight into shipped/. The mirror of # ids_entering_closed, and as there a record that only leaves planned/ enters -# nothing. +# nothing — nor does one the base already holds in shipped/: a reslug inside +# shipped/ on the base's side reads, in the two-dot diff, as a rename back into +# shipped/, and must not satisfy a stale `Delivers:` (iss-2609012047551175's +# twin). The comparison is on the canonical id, so a zero-padded filename on +# either side is the same record. ids_entering_shipped() { - local base="$1" head="$2" + local base="$1" head="$2" shipped_at_base + # `|| exit 2` for the reason ids_entering_closed gives. + shipped_at_base="$(terminal_ids "$base" "$INTENTS_DIR/shipped" "" 'itd-[0-9]+')" || exit 2 + shipped_at_base="$(printf '%s\n' "$shipped_at_base" | while IFS= read -r raw; do canon_itd "$raw"; done)" git diff --name-status --find-renames "$base".."$head" -- "${INTENT_PATHSPECS[@]}" | while IFS=$'\t' read -r status path dest; do - local landed="" + local landed="" id case "$status" in R*) landed="$dest" ;; A) landed="$path" ;; esac case "$landed" in "$INTENTS_DIR/shipped/"*) - canon_itd "$(basename "$landed" | grep -oE '^itd-[0-9]+' || true)" + id="$(canon_itd "$(basename "$landed" | grep -oE '^itd-[0-9]+' || true)")" + [ -n "$id" ] || continue + printf '%s\n' "$shipped_at_base" | grep -qx "$id" && continue + printf '%s\n' "$id" ;; esac done From 7a96f7b0ffb5f0f4b7cf43459d8a2201417e90aa Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:12:37 +0100 Subject: [PATCH 34/78] fix(scripts): the stale-branch diagnosis asks the merge base RS001's stale-branch split named the last base-side commit that touched the record's terminal path as the one that placed it there. Any touch qualified, so a body edit of a record already terminal at the merge base, or a base-side move between terminal folders, was reported as "placed there on main's side ... rebase" when the honest verdict is "terminal before this branch diverged; drop the trailer". The split now asks the merge base's tree whether the record was terminal there, and the placer it names is the base-side commit that added or renamed the path into place (--diff-filter=AR), never a later edit. RS005's twin probe for shipped/ takes the same change. The placer's subject is text the base's history controls and was interpolated into stderr as-is, so a subject carrying a terminal escape sequence reached the reader's terminal. fail(), the one funnel every refusal passes through, now deletes control bytes; that also covers the unreadable Delivers: line RS005 quotes back. The three record-1 cases now assert the drop-the-trailer diagnosis rather than the refusal alone. New cases: a base-side body edit of a record terminal at the merge base (and its shipped/ twin) prescribes no rebase; the placer named is the move, not a later edit; a placer subject with an escape sequence is named without its control bytes. Each watched fail against the previous gate. Refs: iss-2609012047566360 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 76 ++++++++++++++++++++++++- scripts/check-issue-resolution.sh | 58 +++++++++++++++---- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 81ba1ae54..ea2c303c8 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -388,13 +388,15 @@ d="$(stale_trailer_on_terminal_record rs001-base-moved-to-wontfix)" git -C "$d" mv "$ISS_DIR/resolved/iss-999-a-fixture.md" "$ISS_DIR/wontfix/iss-999-a-fixture.md" git -C "$d" commit -qm "chore: reclassify as wontfix" git -C "$d" checkout -q work -expect fail "$d" "RS001 a base-side move resolved/ -> wontfix/ does not satisfy a stale trailer" -- commits main HEAD +expect_refusal_naming "$d" "RS001 a base-side move resolved/ -> wontfix/ does not satisfy a stale trailer" \ + "iss-999 already sat in $ISS_DIR/wontfix/ before this branch diverged from main.*[Dd]rop the trailer" -- commits main HEAD d="$(stale_trailer_on_terminal_record rs001-base-reslugged)" git -C "$d" mv "$ISS_DIR/resolved/iss-999-a-fixture.md" "$ISS_DIR/resolved/iss-999-reslugged.md" git -C "$d" commit -qm "chore: reslug the record" git -C "$d" checkout -q work -expect fail "$d" "RS001 a base-side reslug inside resolved/ does not satisfy a stale trailer" -- commits main HEAD +expect_refusal_naming "$d" "RS001 a base-side reslug inside resolved/ does not satisfy a stale trailer" \ + "iss-999 already sat in $ISS_DIR/resolved/ before this branch diverged from main.*[Dd]rop the trailer" -- commits main HEAD # The same move rewritten past rename detection arrives as a plain add, with no # rename source to read — which is why the test is the base's listing. @@ -408,6 +410,56 @@ git -C "$d" commit -qm "chore: reclassify and rewrite" git -C "$d" checkout -q work expect fail "$d" "RS001 a base-side move rewritten past rename detection does not satisfy a stale trailer" -- commits main HEAD +# --- RS001's stale-branch split asks the merge base (iss-2609012047566360) ---- +# +# Terminal at the merge base, then merely EDITED on the base's side: the edit is +# not a placement, and a rebase cures nothing. The honest verdict is the one +# the untouched case already gets — drop the trailer. +d="$(stale_trailer_on_terminal_record rs001-base-body-edit)" +echo "A later note on the resolved record." >>"$d/$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" add -A +git -C "$d" commit -qm "docs: annotate the resolved record" +git -C "$d" checkout -q work +expect_refusal_naming "$d" "RS001 a base-side body edit of a record terminal at the merge base says to drop the trailer" \ + "iss-999 already sat in $ISS_DIR/resolved/ before this branch diverged from main.*[Dd]rop the trailer" -- commits main HEAD +expect_refusal_not_naming "$d" "RS001 a base-side body edit of a record terminal at the merge base does not prescribe a rebase" \ + "[Rr]ebase" -- commits main HEAD + +# Open at the merge base and placed on the base's side, then edited there: the +# placer named is the commit that MOVED it into resolved/, not the later edit. +d="$(newrepo rs001-placer-names-the-move)" +resolve_record "$d" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +git -C "$d" checkout -q main +git -C "$d" mv "$ISS_DIR/open/iss-999-a-fixture.md" "$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" commit -qm "fix: something (squash of work)" +echo "A later note on the resolved record." >>"$d/$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" add -A +git -C "$d" commit -qm "docs: annotate the resolved record" +git -C "$d" checkout -q work +expect_refusal_naming "$d" "RS001 names the base-side commit that placed the record, not a later edit" \ + "placed there on main's side by [0-9a-f]+ fix: something \\(squash of work\\).*[Rr]ebase onto main" -- commits main HEAD + +# The placer's subject is text the base's history controls. A control sequence +# in it must not reach the terminal through the gate's output. +d="$(newrepo rs001-placer-control-bytes)" +resolve_record "$d" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +git -C "$d" checkout -q main +git -C "$d" mv "$ISS_DIR/open/iss-999-a-fixture.md" "$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" commit -qm "$(printf 'fix: \033[31mred\033[0m squash')" +git -C "$d" checkout -q work +expect_refusal_naming "$d" "RS001 still names a placer whose subject carried control bytes" \ + "placed there on main's side by [0-9a-f]+ fix: \\[31mred\\[0m squash" -- commits main HEAD +expect_refusal_not_naming "$d" "RS001 does not replay the placer subject's control bytes" \ + "$(printf '\033')" -- commits main HEAD + # --- RS002: a stamp added here must name a reachable commit ------------------ d="$(newrepo rs002-bad)" @@ -1139,7 +1191,25 @@ git -C "$d" checkout -q main git -C "$d" mv "$INT_DIR/shipped/itd-6-fixture-6.md" "$INT_DIR/shipped/itd-6-reslugged.md" git -C "$d" commit -qm "docs: reslug itd-6" git -C "$d" checkout -q work -expect fail "$d" "RS005 a base-side reslug inside shipped/ does not satisfy a stale trailer" -- commits main HEAD +expect_refusal_naming "$d" "RS005 a base-side reslug inside shipped/ does not satisfy a stale trailer" \ + "itd-6 already sat in $INT_DIR/shipped/ before this branch diverged from main.*[Dd]rop the trailer" -- commits main HEAD + +# RS001's merge-base split on the intent store: a base-side edit of an intent +# already shipped at the merge base is not a placement (iss-2609012047566360's +# twin). +d="$(newrepo_intents rs005-base-body-edit)" +echo "touched" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing again + +Delivers: itd-6" +git -C "$d" checkout -q main +echo "A later note on the shipped intent." >>"$d/$INT_DIR/shipped/itd-6-fixture-6.md" +git -C "$d" add -A +git -C "$d" commit -qm "docs: annotate itd-6" +git -C "$d" checkout -q work +expect_refusal_not_naming "$d" "RS005 a base-side edit of an intent shipped at the merge base does not prescribe a rebase" \ + "[Rr]ebase" -- commits main HEAD # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 6e976b1c9..4c13ed89d 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -226,8 +226,15 @@ DELIVERS_ID_SHAPED_RE='[A-Za-z]+-[0-9]+' violations=0 +# fail reports one violation. Every refusal passes through here, and several +# carry text the branch under judgement controls — a base-side commit subject in +# the stale-branch diagnosis, an unreadable delivery line quoted back — so control +# bytes are deleted before the message reaches a terminal or a CI log: an escape +# sequence in a commit subject is not the gate's to replay (iss-2609012047566360). +# Deleted rather than escaped, because the byte carries no meaning a reader needs; +# the refusal's own text is printable ASCII and loses nothing. fail() { - printf 'check-issue-resolution: %s\n' "$1" >&2 + printf 'check-issue-resolution: %s\n' "$(printf '%s' "$1" | tr -d '[:cntrl:]')" >&2 violations=$((violations + 1)) } @@ -460,7 +467,7 @@ open_specs_for() { # check_delivery applies RS005 to one declared, canonical itd-N from commit sha. # $shipped is the set of ids entering shipped/ in the range. check_delivery() { - local sha="$1" id="$2" base="$3" head="$4" shipped="$5" behind="$6" + local sha="$1" id="$2" base="$3" head="$4" shipped="$5" behind="$6" mb="$7" printf '%s\n' "$shipped" | grep -qx "$id" && return 0 local says="RS005 commit ${sha:0:12} declares 'Delivers: $id', but" local head_path base_path base_bucket="" @@ -477,10 +484,18 @@ check_delivery() { if [ "$base_bucket" = shipped ]; then # The stale-branch split RS001 draws, for the same reason: whether a rebase # is the remedy turns on WHEN the record reached shipped/. - local placer - placer="$(git log -n1 --format='%h %s' "$head".."$base" -- "$base_path" || true)" - if [ -n "$placer" ]; then - fail "$says $id already sits in $INTENTS_DIR/shipped/ at $base (placed there on $base's side by $placer), and $head is $behind commit(s) behind $base: the delivery reached $base outside $base..$head, so this trailer describes work $base already holds. Rebase onto $base; if this commit survives the rebase, drop the trailer." + # Asked of the merge base's tree, and the placer is the commit that added + # or renamed the path into place — iss-2609012047566360, as RS001. + local mb_path mb_bucket="" + if [ -n "$mb" ]; then + mb_path="$(intent_path "$mb" "$id")" + [ -n "$mb_path" ] && mb_bucket="$(bucket_of "$mb_path")" + fi + if [ "$mb_bucket" != shipped ]; then + local placer placed_by="after this branch diverged" + placer="$(git log -n1 --diff-filter=AR --format='%h %s' "$head".."$base" -- "$base_path" || true)" + [ -n "$placer" ] && placed_by="by $placer" + fail "$says $id already sits in $INTENTS_DIR/shipped/ at $base (placed there on $base's side $placed_by), and $head is $behind commit(s) behind $base: the delivery reached $base outside $base..$head, so this trailer describes work $base already holds. Rebase onto $base; if this commit survives the rebase, drop the trailer." else fail "$says $id already sat in $INTENTS_DIR/shipped/ before this branch diverged from $base: the trailer names an intent delivered before this commit. Drop the trailer." fi @@ -598,6 +613,11 @@ check_commits() { local declared="" delivered="" local behind behind="$(git rev-list --count "$head".."$base")" + # The fork point the stale-branch diagnoses ask about. None (unrelated + # histories) leaves it empty, and every record then reads as placed after + # the fork, which is where head..base puts all of base's history anyway. + local mb + mb="$(git merge-base "$base" "$head" 2>/dev/null || true)" local scanned=0 while IFS= read -r sha; do [ -n "$sha" ] || continue @@ -632,7 +652,7 @@ check_commits() { local cid cid="$(canon_itd "$raw")" delivered="$delivered $cid" - check_delivery "$sha" "$cid" "$base" "$head" "$shipped" "$behind" + check_delivery "$sha" "$cid" "$base" "$head" "$shipped" "$behind" "$mb" done continue fi @@ -668,11 +688,25 @@ check_commits() { # terminal already at the merge base, in which case the trailer # names an issue resolved before this commit and nothing but # dropping it helps. The behind-count alone cannot tell them apart; - # the record's base-side history can. - local placer - placer="$(git log -n1 --format='%h %s' "$head".."$base" -- "$base_path" || true)" - if [ -n "$placer" ]; then - fail "RS001 commit ${sha:0:12} declares 'Resolves: $id', but $id already sits in $ISSUES_DIR/$base_status/ at $base (placed there on $base's side by $placer), and $head is $behind commit(s) behind $base: the resolution reached $base outside $base..$head, so this trailer describes work $base already holds. Rebase onto $base; if this commit survives the rebase, drop the trailer." + # the merge base's tree can. + # + # It is asked of that TREE, not of which commits touched the path + # since (iss-2609012047566360): when any touch qualified, a body + # edit of a record already terminal at the merge base — or a + # base-side move between terminal folders — was reported as the + # placement, with a rebase that cures nothing. The placer named is + # the base-side commit that ADDED or renamed the path into place, + # never a later edit of it. + local mb_status="" mb_path + if [ -n "$mb" ]; then + mb_path="$(record_path "$mb" "$id")" + [ -n "$mb_path" ] && mb_status="$(status_of "$mb_path")" + fi + if [ "$mb_status" != resolved ] && [ "$mb_status" != wontfix ]; then + local placer placed_by="after this branch diverged" + placer="$(git log -n1 --diff-filter=AR --format='%h %s' "$head".."$base" -- "$base_path" || true)" + [ -n "$placer" ] && placed_by="by $placer" + fail "RS001 commit ${sha:0:12} declares 'Resolves: $id', but $id already sits in $ISSUES_DIR/$base_status/ at $base (placed there on $base's side $placed_by), and $head is $behind commit(s) behind $base: the resolution reached $base outside $base..$head, so this trailer describes work $base already holds. Rebase onto $base; if this commit survives the rebase, drop the trailer." else fail "RS001 commit ${sha:0:12} declares 'Resolves: $id', but $id already sat in $ISSUES_DIR/$base_status/ before this branch diverged from $base: the trailer names an issue that was resolved before this commit. Drop the trailer." fi From aa9a1c24142996388b424209e9ddf69b103a4ea7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:12:48 +0100 Subject: [PATCH 35/78] fix(scripts): a revert in the range withdraws a Delivers or Resolves RS005 judged every Delivers: trailer in the range, so once a commit carrying one was on a pushed branch, a git revert of it could not take the declaration back: the revert moved the intent out of shipped/, the trailer stayed, and lint-issues refused the branch with no exit short of a new branch and a new pull request. A commit that a later commit of the same range reverts, in git revert's own words ("This reverts commit ."), now has its declarations withdrawn; a revert of that revert reinstates them. The reverted commit must be in the range and a strict ancestor of the revert, so a hand-written line naming a commit outside the range withdraws nothing and no cycle can form. The gate prints one line naming the withdrawal. RS004 still reads the withdrawn message. RS001 has the identical shape for Resolves: (a resolution reverted on the branch puts the record back in open/ and leaves the trailer behind), so the one withdrawal covers both trailers. Cases, watched fail against the previous gate: a reverted delivery passes, a reverted resolution passes. Pinned both ways: a revert of the revert is refused again, and a "reverts" line naming a commit outside the range withdraws nothing. Refs: iss-2609240646533487 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 54 ++++++++++++++++++++++++ scripts/check-issue-resolution.sh | 56 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index ea2c303c8..4221fc624 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -1211,6 +1211,60 @@ git -C "$d" checkout -q work expect_refusal_not_naming "$d" "RS005 a base-side edit of an intent shipped at the merge base does not prescribe a rebase" \ "[Rr]ebase" -- commits main HEAD +# --- A revert in the range withdraws a declaration (iss-2609240646533487) ----- +# +# Once a commit carrying `Delivers:` is on a pushed branch, a revert is the one +# honest way to take the delivery back — and the trailer stays in the range. The +# revert names the reverted commit in git's own words, and that withdraws it. +d="$(newrepo_intents rs005-reverted-delivery)" +echo "touched" >>"$d/README.md" +ship_intent "$d" 7 +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +git -C "$d" revert --no-edit HEAD >/dev/null +expect pass "$d" "RS005 a delivery reverted in the same range is withdrawn" -- commits main HEAD + +# A revert of the revert reinstates the declaration, and the rule holds it again. +d="$(newrepo_intents rs005-revert-reverted)" +echo "touched" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +git -C "$d" revert --no-edit HEAD >/dev/null +git -C "$d" revert --no-edit HEAD >/dev/null +expect_refusal_naming "$d" "RS005 a revert of the revert reinstates the declaration" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD + +# A "reverts" line naming a commit OUTSIDE the range withdraws nothing: the +# range's own declaration stands. +d="$(newrepo_intents rs005-revert-outside-range)" +echo "touched" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +echo "more" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "Revert something on main + +This reverts commit $(git -C "$d" rev-parse main)." +expect_refusal_naming "$d" "RS005 a revert naming a commit outside the range withdraws nothing" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD + +# The same withdrawal for RS001: a resolution reverted on the branch puts the +# record back in open/, and its `Resolves:` goes with it. +d="$(newrepo rs001-reverted-resolution)" +resolve_record "$d" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +git -C "$d" revert --no-edit HEAD >/dev/null +expect pass "$d" "RS001 a resolution reverted in the same range is withdrawn" -- commits main HEAD + # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary # case (a trailer whose record stays where it was); each refusal is normalised by diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 4c13ed89d..9d7c987d0 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -541,6 +541,47 @@ check_delivery() { esac } +# revert_pairs prints " " for every non-merge commit +# in base..head whose message says, in git revert's own words, that it reverts +# ANOTHER commit of the same range (iss-2609240646533487). A pushed branch has no +# other way to take a declaration back: the trailer stays in the range after the +# revert takes the record back out of the terminal folder, and without this the +# only exit was a new branch and a new pull request. +# +# The reverted commit must be a strict ancestor of the one reverting it, which is +# true of every revert git writes (it names a commit that existed) and makes a +# cycle of hand-written "reverts" lines impossible, so the recursion below always +# ends. An abbreviated sha is resolved; one naming nothing, or a commit outside +# the range, withdraws nothing. +revert_pairs() { + local base="$1" head="$2" range="$3" r x full + git log --no-merges --format=%H -E --grep='This reverts commit [0-9a-f]{7,64}' "$base".."$head" | + while IFS= read -r r; do + [ -n "$r" ] || continue + for x in $(git show -s --format='%B' "$r" | grep -E '^This reverts commit [0-9a-f]{7,64}' | grep -oE '[0-9a-f]{7,64}' || true); do + full="$(git rev-parse -q --verify "${x}^{commit}" 2>/dev/null || true)" + [ -n "$full" ] && [ "$full" != "$r" ] || continue + printf '%s\n' "$range" | grep -qx "$full" || continue + git merge-base --is-ancestor "$full" "$r" 2>/dev/null || continue + printf '%s %s\n' "$r" "$full" + done + done +} + +# withdrawn_by prints the commit that withdraws sha's declarations — a revert of +# it in the range that is not itself reverted there (a revert of a revert +# reinstates) — and returns 0, or returns 1 when nothing withdraws it. +withdrawn_by() { + local sha="$1" pairs="$2" r + for r in $(printf '%s\n' "$pairs" | awk -v s="$sha" '$2 == s { print $1 }'); do + if ! withdrawn_by "$r" "$pairs" >/dev/null; then + printf '%s\n' "$r" + return 0 + fi + done + return 1 +} + check_pr() { local title_file="$1" body_file="$2" title body declared local f @@ -610,6 +651,12 @@ check_commits() { local shipped shipped="$(ids_entering_shipped "$base" "$head" | sort -u)" + # A declaration a later commit of the range reverts is withdrawn: its + # `Resolves:` and `Delivers:` lines are not held to a move the revert undid. + # RS004 still reads the message — a withdrawn commit named what it named. + local reverts + reverts="$(revert_pairs "$base" "$head" "$range")" + local declared="" delivered="" local behind behind="$(git rev-list --count "$head".."$base")" @@ -632,6 +679,15 @@ check_commits() { msg="$(git show -s --format='%B' "$sha")" check_mentions "commit ${sha:0:12}" "$msg" "$(declared_ids "$msg")" scanned=$((scanned + 1)) + if [ -n "$reverts" ]; then + local withdrawer + if withdrawer="$(withdrawn_by "$sha" "$reverts")"; then + if printf '%s\n' "$msg" | grep -qE "$TRAILER_RE|$DELIVERS_LOOSE_RE"; then + echo "check-issue-resolution: RS001/RS005 commit ${sha:0:12} is reverted in this range by ${withdrawer:0:12}, so its Resolves:/Delivers: declarations are withdrawn" + fi + continue + fi + fi while IFS= read -r line; do # RS005 — a declared delivery must ship the intent. Judged on the same # lines RS001 reads; a line is one trailer or the other, never both. From 64b4ac9b1d3f48df71960c61a801bd2015b07f24 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:13:10 +0100 Subject: [PATCH 36/78] =?UTF-8?q?chore:=20resolve=20iss-2609012047551175?= =?UTF-8?q?=20=E2=80=94=20entering=20is=20from=20outside=20a=20terminal=20?= =?UTF-8?q?folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609012047551175 Assisted-by: Claude:claude-opus-5-5 --- ...ing-closed-in-scripts-check-issue-resolution-sh-acc.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md (57%) diff --git a/.abcd/work/issues/open/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md b/.abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md similarity index 57% rename from .abcd/work/issues/open/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md rename to .abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md index e25062065..e6aaeecfc 100644 --- a/.abcd/work/issues/open/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md +++ b/.abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" +resolution: "ids_entering_closed now counts an id as entering resolved/ or wontfix/ only when the base does not already hold it in a terminal folder (the base's listing, so a move rewritten past rename detection is caught too); ids_entering_shipped takes the same filter for shipped/. Proved by four cases in scripts/check-issue-resolution-cases.sh (base-side resolved->wontfix move, base-side reslug, the move rewritten past rename detection, the shipped/ reslug twin), each passing the old gate and refused by the new one." +impact: internal +resolved_by: + commit: "46ae88404" --- ids_entering_closed in scripts/check-issue-resolution.sh accepts a rename whose SOURCE is already a terminal folder: it keys on the destination alone, so a record that moves resolved/ to wontfix/, or is reslugged within resolved/, counts as ENTERING a terminal folder. Two topologies let a stale trailer pass silently: main has since moved the record from resolved/ to wontfix/ (the branch's Resolves trailer is satisfied by a move it did not make), and main has reslugged the record inside resolved/ (the rename's destination is terminal, the id is extracted from the new basename, and the trailer is satisfied by a rename). Pre-existing, untouched by the hygiene branch; found by the ruthless review of it. The honest test is that the rename's source is NOT a terminal folder, or that the record was open at the base. + +## Grounds + +- pursued: a stale Resolves: or Delivers: trailer is no longer satisfied by a base-side move between or within terminal folders; a same-change capture-and-resolve or an honest open->resolved move still passes, and a clean case in the suite failing would show it wrong From 55da00c3d093cb4d111aa09032d819a7319b5022 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:13:29 +0100 Subject: [PATCH 37/78] =?UTF-8?q?chore:=20resolve=20iss-2609012047566360?= =?UTF-8?q?=20=E2=80=94=20the=20stale-branch=20split=20asks=20the=20merge?= =?UTF-8?q?=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609012047566360 Assisted-by: Claude:claude-opus-5-5 --- ...r-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md (53%) diff --git a/.abcd/work/issues/open/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md b/.abcd/work/issues/resolved/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md similarity index 53% rename from .abcd/work/issues/open/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md rename to .abcd/work/issues/resolved/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md index 310d2de6c..ea7a972e3 100644 --- a/.abcd/work/issues/open/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md +++ b/.abcd/work/issues/resolved/iss-2609012047566360-the-placer-probe-in-rs001-s-stale-branch-diagnosis-scripts-c.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" +resolution: "RS001's stale-branch split (and RS005's twin for shipped/) now asks the merge base's tree whether the record was already terminal there, and names as placer the base-side commit that added or renamed the path into place (--diff-filter=AR), never a later edit; fail() deletes control bytes from every refusal, so a placer subject carrying an escape sequence no longer reaches the terminal. Proved by cases in scripts/check-issue-resolution-cases.sh: a base-side body edit of a record terminal at the merge base (and the shipped/ twin) prescribes no rebase, the placer named is the move and not a later edit, and an escape sequence in the placer subject is named without its control bytes; each failed against the previous gate." +impact: internal +resolved_by: + commit: "7a96f7b0f" --- The placer probe in RS001's stale-branch diagnosis (scripts/check-issue-resolution.sh) names the last base-side commit that touched the record's terminal path after divergence, as evidence that the base placed the record there after the branch forked. Any touch qualifies, so a body edit of a record that was already terminal at the merge base reports 'placed there on main's side by … rebase' when the honest verdict is 'terminal before this branch diverged; drop the trailer'. The probe should key on the commit that ADDED or renamed the path into the terminal folder (--diff-filter=AR) or compare the merge base's tree, not on any touch. Separately, the base-side commit subject is interpolated into the stderr message unsanitised; a subject carrying terminal control sequences would reach the terminal through the gate's output. Found by the ruthless review of the hygiene branch; left open as a follow-up. + +## Grounds + +- pursued: a trailer naming a record terminal before the branch diverged is told to drop the trailer and a truly stale branch is still told to rebase naming the placing commit; the existing stale-branch and terminal-before-divergence cases going red would show it wrong From 61baebae418d04f63e9f5986fc86c9450a8527b0 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:13:37 +0100 Subject: [PATCH 38/78] =?UTF-8?q?chore:=20resolve=20iss-2609240646533487?= =?UTF-8?q?=20=E2=80=94=20a=20revert=20in=20the=20range=20withdraws=20a=20?= =?UTF-8?q?declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609240646533487 Assisted-by: Claude:claude-opus-5-5 --- ...evert-cannot-withdraw-a-delivers-trailer-from-rs005.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md (59%) diff --git a/.abcd/work/issues/open/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md b/.abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md similarity index 59% rename from .abcd/work/issues/open/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md rename to .abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md index 2003d1554..864b231b7 100644 --- a/.abcd/work/issues/open/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md +++ b/.abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" +resolution: "A commit that a later commit of the same range reverts, in git revert's own words, has its Resolves: and Delivers: declarations withdrawn (RS001 and RS005 alike); a revert of that revert reinstates them, and a reverts line naming a commit outside the range, or not an ancestor of the revert, withdraws nothing. Proved by cases in scripts/check-issue-resolution-cases.sh: a reverted delivery and a reverted resolution pass (both refused by the previous gate), a revert of the revert is refused again, and a revert naming a commit outside the range withdraws nothing." +impact: internal +resolved_by: + commit: "aa9a1c241" --- RS005 judges every `Delivers: itd-N` trailer in the range, so once a commit carrying the trailer is on a branch, a later `git revert` of that commit cannot withdraw the declaration: the revert takes the intent back out of `shipped/`, the trailer stays in the range, and `lint-issues` refuses the branch. In autonomous run A the load-check lane (itd-2609231434459890) shipped its intent, review found that shipping it over-claimed, and the fix round reverted the ship commit and closed the spec with a remainder instead, which RS005 refused. The branch had not been pushed, so the orchestrator rebuilt it from the commits before the ship plus cherry-picks, with no ship-and-revert pair in the history; a pushed branch under an open pull request has no such exit short of a new branch and a new pull request. Wanted: RS005 treats a delivery whose commit is reverted within the same range as withdrawn (git names the reverted commit in the revert's message), or its refusal names the rebuild as the remedy. + +## Grounds + +- pursued: a pushed branch can take a delivery or resolution back with git revert instead of being rebuilt; a declaration left in force after a range-internal revert, or one withdrawn by a hand-written line naming a commit outside the range, would show it wrong From 5b7c79426d45bbf1fc93012b91622fc2c8dd5e1c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:20:58 +0100 Subject: [PATCH 39/78] fix(scripts): feed grep -q from a here-string, never a pipe Both CI gate scripts tested membership and matches by piping printf into grep -q under set -o pipefail. grep -q exits at its first match, printf's next write takes SIGPIPE, the pipeline returns 141, and the test reads as no match: a race whenever printf needs more than one write, and certain past one pipe buffer (64 KiB). Two full-history runs of check-issue-resolution.sh over the same range at the same commits differed by 47 violations, and check-attribution.sh's footer, co-authorship and trailer checks could pass a long body whose banned line matched early. Every such test now reads a here-string, which bash writes in full before grep reads, and RS006's frontmatter reader reads to the end instead of exiting at the closing delimiter, which left git writing into a closed pipe. No rule changes; only the reading. Cases, each watched fail against the previous scripts: a message declaring 1,100 long ids (about 70 KiB) is read whole by RS004, and a Co-authored-by line at the top of a 200 KiB pull-request body is refused. The capture travels with this change. Refs: iss-2609281314564762 Assisted-by: Claude:claude-opus-5-5 --- ...-scripts-test-membership-and-matches-by.md | 14 +++++++++++ scripts/check-attribution-cases.sh | 11 +++++++++ scripts/check-attribution.sh | 16 ++++++------- scripts/check-issue-resolution-cases.sh | 24 ++++++++++++++++--- scripts/check-issue-resolution.sh | 14 +++++------ 5 files changed, 61 insertions(+), 18 deletions(-) create mode 100644 .abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md diff --git a/.abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md b/.abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md new file mode 100644 index 000000000..286c101c1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609281314564762" +slug: "the-two-ci-gate-scripts-test-membership-and-matches-by" +severity: "major" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25 (lane drainScr, full-history before/after runs)" +origin: researcher-authored +production_mode: hand-written +found_at: "scripts/check-issue-resolution.sh" +--- + +The two CI gate scripts test membership and matches by piping printf into grep -q under set -o pipefail, and that pipeline is a race whenever printf needs more than one write: grep -q exits at its first match, printf's next write takes SIGPIPE, the pipeline returns 141, and the test reads as no match. Under load the race loses at a few KiB; past one pipe buffer (64 KiB) it loses every time. Measured: a 50 KiB id list missed 0 of 30 runs and a 70 KiB one 30 of 30; a here-string missed none. In scripts/check-issue-resolution.sh the RS001 membership test against the ids entering a terminal folder, RS004's declared-id test, and RS005's shipped test all take this shape, so two full-history runs of the gate over the same range at the same commits differed by 47 violations (39 RS001 refusals of records that did enter resolved/, seven RS004 refusals of ids a 190-line Refs: block declared, one RS005), and the base-listing membership test the lane adds for iss-2609012047551175 would read a record terminal at the base as absent on a real ledger, failing open. In scripts/check-attribution.sh check_text pipes a whole commit message or pull-request body into grep -q for the tool-footer, Co-authored-by and trailer checks, so a body longer than the buffer whose banned line is matched before the last write can pass. Fix: feed grep from a here-string, which bash writes in full before grep reads, so no writer is left to take SIGPIPE. diff --git a/scripts/check-attribution-cases.sh b/scripts/check-attribution-cases.sh index 7f552867f..c21e6470b 100755 --- a/scripts/check-attribution-cases.sh +++ b/scripts/check-attribution-cases.sh @@ -698,6 +698,17 @@ merge_as 'Alex Reppel' '77722411+REPPL@users.noreply.github.com' \ 'GitHub' 'noreply@github.com' 'Merge pull request #1 from intentdriven/side' commits_case accept "forge merge commit, human author, no trailer" +# --- A long artefact is read whole (iss-2609281314564762) --------------------- +# printf piped into grep -q under pipefail is a race: grep exits at its first +# match, printf's next write takes SIGPIPE, and the pipeline reads as "no +# match". Past one pipe buffer (64 KiB) it is the rule, so a banned line +# matched early in a long body passed. This body is about 200 KiB. +long_filler="$(awk 'BEGIN { for (i = 0; i < 4000; i++) print "Filler line to push the body past a pipe buffer." }')" +case_is reject "co-authorship early in a long body" "Co-authored-by: Someone +$long_filler + +Assisted-by: Claude:claude-opus-5" + # --- The human-only declaration ----------------------------------------------- # A change no AI touched discloses that positively; silence stays refused, # because an absent trailer and a forgotten one are indistinguishable. diff --git a/scripts/check-attribution.sh b/scripts/check-attribution.sh index 9f9768331..bf9ad5fed 100755 --- a/scripts/check-attribution.sh +++ b/scripts/check-attribution.sh @@ -460,13 +460,13 @@ strip_fenced_blocks() { # bump; landing it as a human is. check_ident() { local label="$1" role="$2" name="$3" mail="$4" kind="" - if printf '%s' "$name" | grep -Eiq "$AI_IDENT_NAME_RE" || - printf '%s' "$mail" | grep -Eiq "$AI_IDENT_MAIL_RE"; then + if grep -Eiq "$AI_IDENT_NAME_RE" <<<"$name" || + grep -Eiq "$AI_IDENT_MAIL_RE" <<<"$mail"; then kind="an AI" - elif printf '%s' "$name" | grep -Eiq "$MACHINE_NAME_RE" || - printf '%s' "$mail" | grep -Eiq "$MACHINE_MAIL_RE"; then + elif grep -Eiq "$MACHINE_NAME_RE" <<<"$name" || + grep -Eiq "$MACHINE_MAIL_RE" <<<"$mail"; then kind="a machine" - elif [ "$role" = author ] && printf '%s' "$mail" | grep -Eiq "$AUTHOR_ONLY_MAIL_RE"; then + elif [ "$role" = author ] && grep -Eiq "$AUTHOR_ONLY_MAIL_RE" <<<"$mail"; then kind="a machine" else return 0 @@ -496,7 +496,7 @@ check_text() { # ban regexes are unanchored at line end and so are unaffected; the commits arm # is already LF (git normalises %B), so this is a no-op there. text="$(printf '%s' "$text" | tr -d '\r')" - if printf '%s' "$text" | grep -Eq "$GENERATED_RE"; then + if grep -Eq "$GENERATED_RE" <<<"$text"; then echo "check-attribution: $label carries a tool's default 'generated with' footer" >&2 note "A 'Generated with ' footer names a tool outside the two credit surfaces" note "AGENTS.md sanctions (the README badge and ACKNOWLEDGEMENTS.md). Replace it with" @@ -504,7 +504,7 @@ check_text() { fail=1 return fi - if printf '%s' "$text" | grep -Eq "$COAUTHOR_RE"; then + if grep -Eq "$COAUTHOR_RE" <<<"$text"; then echo "check-attribution: $label carries a 'Co-authored-by:' trailer" >&2 note "abcd never uses Co-Authored-By: for AI — it asserts an authorship the tool does" note "not hold and inflates the contributor graph. Disclosure goes in the kernel" @@ -515,7 +515,7 @@ check_text() { fail=1 return fi - if ! printf '%s' "$text" | grep -Eq "$TRAILER_RE" && ! printf '%s' "$text" | grep -Eq "$NONE_RE"; then + if ! grep -Eq "$TRAILER_RE" <<<"$text" && ! grep -Eq "$NONE_RE" <<<"$text"; then echo "check-attribution: $label has no 'Assisted-by:' trailer" >&2 note "Add a final line of the form: Assisted-by: :" note "for example Assisted-by: Claude:claude-opus-5 or Assisted-by: Claude:claude-opus-5[1m]" diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 4221fc624..481f69aad 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -91,7 +91,7 @@ expect_refusal_naming() { if [ "$rc" -ne 1 ]; then printf 'cases: FAIL %s — expected a refusal (exit 1), got exit %d:\n%s\n' "$label" "$rc" "$out" >&2 failures=$((failures + 1)) - elif ! printf '%s\n' "$out" | grep -qE -- "$pattern"; then + elif ! grep -qE -- "$pattern" <<<"$out"; then printf 'cases: FAIL %s — refused, but the message does not carry the diagnosis (want /%s/):\n%s\n' "$label" "$pattern" "$out" >&2 failures=$((failures + 1)) else @@ -113,7 +113,7 @@ expect_refusal_not_naming() { if [ "$rc" -ne 1 ]; then printf 'cases: FAIL %s — expected a refusal (exit 1), got exit %d:\n%s\n' "$label" "$rc" "$out" >&2 failures=$((failures + 1)) - elif printf '%s\n' "$out" | grep -qE -- "$pattern"; then + elif grep -qE -- "$pattern" <<<"$out"; then printf 'cases: FAIL %s — refused, but the message names a remedy that does not apply (/%s/):\n%s\n' "$label" "$pattern" "$out" >&2 failures=$((failures + 1)) else @@ -460,6 +460,24 @@ expect_refusal_naming "$d" "RS001 still names a placer whose subject carried con expect_refusal_not_naming "$d" "RS001 does not replay the placer subject's control bytes" \ "$(printf '\033')" -- commits main HEAD +# --- Membership on a large set is deterministic (iss-2609281314564762) ------ +# +# printf piped into grep -q under pipefail is a race: grep exits at its first +# match, printf's next write takes SIGPIPE, the pipeline returns 141 and the +# test reads as "no match". Past one pipe buffer (64 KiB) it stops being a race +# and becomes the rule, and the same membership idiom tested RS001's entering +# set, RS004's declared set and RS005's shipped set. Here a message declares +# 1,100 long ids (about 70 KiB) and names them all: every mention is declared, +# and the ones sorting first were read as undeclared. +d="$(newrepo large-declared-set)" +refs="$(awk 'BEGIN { for (i = 1000; i < 2100; i++) printf "Refs: iss-%s%s\n", i, "00000000000000000000000000000000000000000000000000000000" }')" +echo "touched" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "chore: touch many records + +$refs" +expect pass "$d" "RS004 a declaration set past one pipe buffer is read whole" -- commits main HEAD + # --- RS002: a stamp added here must name a reachable commit ------------------ d="$(newrepo rs002-bad)" @@ -1294,7 +1312,7 @@ shape_itd="$(shape_of "$d_itd")" if [ "$shape_iss" != "$shape_itd" ]; then printf 'cases: FAIL RS005 refusal shape differs from RS001'"'"'s:\n--- RS001\n%s\n--- RS005\n%s\n' "$shape_iss" "$shape_itd" >&2 failures=$((failures + 1)) -elif ! printf '%s\n' "$shape_itd" | grep -q ' commit ' || ! printf '%s\n' "$shape_itd" | grep -qx 'exit=1'; then +elif ! grep -q ' commit ' <<<"$shape_itd" || ! grep -qx 'exit=1' <<<"$shape_itd"; then printf 'cases: FAIL RS005 refusal shape — the normaliser matched neither refusal, so the comparison proves nothing:\n%s\n' "$shape_itd" >&2 failures=$((failures + 1)) else diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 9d7c987d0..ef548b8ab 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -277,7 +277,7 @@ ids_entering_closed() { "$ISSUES_DIR/resolved/"* | "$ISSUES_DIR/wontfix/"*) id="$(basename "$landed" | grep -oE '^iss-[0-9]+' || true)" [ -n "$id" ] || continue - printf '%s\n' "$terminal_at_base" | grep -qx "$id" && continue + grep -qx "$id" <<<"$terminal_at_base" && continue printf '%s\n' "$id" ;; esac @@ -363,7 +363,7 @@ mentioned_ids() { check_mentions() { local label="$1" text="$2" declared="$3" id for id in $(mentioned_ids "$text"); do - printf '%s\n' "$declared" | grep -qx "$id" && continue + grep -qx "$id" <<<"$declared" && continue fail "RS004 $label names $id without declaring its relation to it. Add exactly one declaration line: 'Resolves: $id' if this change fixes it (RS001 then requires the record to enter $ISSUES_DIR/resolved/ or $ISSUES_DIR/wontfix/ in the same change), or 'Refs: $id' if it is touched but not fixed (informational; no ledger move required). Those two spellings are the whole vocabulary — 'Ref:', 'See:' and 'Related:' are not declarations." done } @@ -413,7 +413,7 @@ ids_entering_shipped() { "$INTENTS_DIR/shipped/"*) id="$(canon_itd "$(basename "$landed" | grep -oE '^itd-[0-9]+' || true)")" [ -n "$id" ] || continue - printf '%s\n' "$shipped_at_base" | grep -qx "$id" && continue + grep -qx "$id" <<<"$shipped_at_base" && continue printf '%s\n' "$id" ;; esac @@ -468,7 +468,7 @@ open_specs_for() { # $shipped is the set of ids entering shipped/ in the range. check_delivery() { local sha="$1" id="$2" base="$3" head="$4" shipped="$5" behind="$6" mb="$7" - printf '%s\n' "$shipped" | grep -qx "$id" && return 0 + grep -qx "$id" <<<"$shipped" && return 0 local says="RS005 commit ${sha:0:12} declares 'Delivers: $id', but" local head_path base_path base_bucket="" head_path="$(intent_path "$head" "$id")" @@ -561,7 +561,7 @@ revert_pairs() { for x in $(git show -s --format='%B' "$r" | grep -E '^This reverts commit [0-9a-f]{7,64}' | grep -oE '[0-9a-f]{7,64}' || true); do full="$(git rev-parse -q --verify "${x}^{commit}" 2>/dev/null || true)" [ -n "$full" ] && [ "$full" != "$r" ] || continue - printf '%s\n' "$range" | grep -qx "$full" || continue + grep -qx "$full" <<<"$range" || continue git merge-base --is-ancestor "$full" "$r" 2>/dev/null || continue printf '%s %s\n' "$r" "$full" done @@ -682,7 +682,7 @@ check_commits() { if [ -n "$reverts" ]; then local withdrawer if withdrawer="$(withdrawn_by "$sha" "$reverts")"; then - if printf '%s\n' "$msg" | grep -qE "$TRAILER_RE|$DELIVERS_LOOSE_RE"; then + if grep -qE "$TRAILER_RE|$DELIVERS_LOOSE_RE" <<<"$msg"; then echo "check-issue-resolution: RS001/RS005 commit ${sha:0:12} is reverted in this range by ${withdrawer:0:12}, so its Resolves:/Delivers: declarations are withdrawn" fi continue @@ -720,7 +720,7 @@ check_commits() { local id for id in $(printf '%s\n' "$line" | grep -oE 'iss-[0-9]+'); do declared="$declared $id" - printf '%s\n' "$closed" | grep -qx "$id" && continue + grep -qx "$id" <<<"$closed" && continue local head_path base_path base_status head_path="$(record_path "$head" "$id")" base_path="$(record_path "$base" "$id")" From bd3f55e680517a10711987e536b0e6872856be5d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:21:09 +0100 Subject: [PATCH 40/78] fix(scripts): a configured automation name is a machine identity The attribution gate's machine signal was the forge's own [bot] suffix and bot mailbox, which catches only the automations the forge itself stamps. An identity such as semantic-release-bot at a forge no-reply address, a self-hosted CI account committing under a configured name, or a forge whose app suffix differs matched no list and was judged a human. The structural signal now also refuses a trailing bot, robot or automation word ending the display name or the mailbox's local part, in both roles. The word must stand alone, so Talbot, Abbott and jean.bot@ pass; a forge privacy address stays a person's. The stated over-reach is a person whose display name ends in the separate word Bot, refused loudly and named, and the comment says which shapes stay out of reach (a person-shaped name and mailbox, another trailing word, name.bot@). AGENTS.md and CONTRIBUTING.md list six signals instead of five. Cases, the four refusals watched pass the previous gate: semantic-release- bot at a forge no-reply address, Renovate Bot, release_automation@, and a ci-robot committer on a human-authored commit are refused; Ada Talbot at a forge privacy address and jean.bot@ still pass. Refs: iss-2609090951276167 Assisted-by: Claude:claude-opus-5-5 --- AGENTS.md | 16 +++++++++----- CONTRIBUTING.md | 12 ++++++---- scripts/check-attribution-cases.sh | 32 +++++++++++++++++++++++++++ scripts/check-attribution.sh | 35 ++++++++++++++++++++++++++++-- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e0421bf5..276748883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -397,15 +397,19 @@ irreversible; guessing downward costs nothing.** there asserts an authorship it does not hold — and a squash merge re-appends a mis-identified branch author as a co-author, inflating the graph again on every squash. `scripts/check-attribution.sh commits` reads the identity of every - commit in a range, merge commits included, and refuses one on any of five - signals. Four are checked in both roles: an assistant vendor's name standing + commit in a range, merge commits included, and refuses one on any of six + signals. Five are checked in both roles: an assistant vendor's name standing alone as the identity name (`Claude`, `Copilot`, `Gemini` and their kin, matched whole so a human named Claudette passes); an assistant vendor's mail domain (`@anthropic.com`, `@openai.com`); the forge's own `[bot]` name suffix; - and a bot mailbox (`NNNN+name[bot]@users.noreply.github.com`, or - `@dependabot.com`). The last two are structural rather than nominal, which is - why a second automation lands in the right place with no edit to the list. The - fifth signal is checked in the AUTHOR role only: **any** address whose mailbox + a bot mailbox (`NNNN+name[bot]@users.noreply.github.com`, or + `@dependabot.com`); and a trailing `bot`, `robot` or `automation` word ending + the name or the mailbox's local part (`semantic-release-bot`, `Renovate Bot`, + `ci_bot@`), standing alone so Talbot and `jean.bot@` pass. The last three are + structural rather than nominal, which is why a second automation lands in the + right place with no edit to the list; a machine configured with a person-shaped + name and mailbox stays out of reach, and the reviewer is the check on it. The + sixth signal is checked in the AUTHOR role only: **any** address whose mailbox begins `noreply@` or `donotreply@` (with or without hyphens), whatever the host — it is not scoped to a vendor, because an address named for not being read names no person in the role that claims authorship. It is refuse-machines, not an diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ede809f1..90a84b6b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,18 +105,22 @@ disclosure, and never an authorship assertion for a tool. The rules: responsibility. - **Commit as yourself.** The gate reads the git author AND committer of every commit in a pull request, merge commits included, and refuses a machine - identity on any of five signals. Four apply to both roles: an assistant + identity on any of six signals. Five apply to both roles: an assistant vendor's name standing alone (`Claude`, `Copilot`, `Gemini` and their kin, matched whole, so a human named Claudette passes); an assistant vendor's mail domain (`@anthropic.com`, `@openai.com`); the forge's own `[bot]` name suffix; - and a bot mailbox (`NNNN+name[bot]@users.noreply.github.com`, or - `@dependabot.com`). The fifth applies to the AUTHOR role only: **any** address + a bot mailbox (`NNNN+name[bot]@users.noreply.github.com`, or + `@dependabot.com`); and a trailing `bot`, `robot` or `automation` word ending + the name or the mailbox's local part (`semantic-release-bot`, `Renovate Bot`, + `ci_bot@`), standing alone so Talbot and `jean.bot@` pass (a person whose name + ends in the word Bot is refused too, the one over-reach the rule accepts). + The sixth applies to the AUTHOR role only: **any** address whose mailbox begins `noreply@` or `donotreply@`, with or without hyphens and whatever the host, not just a vendor's. Your forge privacy address (`1234+you@users.noreply.github.com`) is yours and passes — the `[bot]` marker in the mailbox is what marks a machine, not the `users.noreply.github.com` host — and the forge's own `GitHub ` committer stamp on a - web-UI merge passes too, which is why the fifth signal is author-only. Set + web-UI merge passes too, which is why the sixth signal is author-only. Set `user.name` and `user.email` to a human before you commit; the assistant belongs in the trailer, never in the identity fields the contributor graph reads. An automated dependency bump is therefore landed by a human rather than diff --git a/scripts/check-attribution-cases.sh b/scripts/check-attribution-cases.sh index c21e6470b..9b445a8ed 100755 --- a/scripts/check-attribution-cases.sh +++ b/scripts/check-attribution-cases.sh @@ -643,6 +643,38 @@ commit_as 'Alex Reppel' '77722411+REPPL@users.noreply.github.com' \ 'GitHub' 'noreply@github.com' "$MSG_OK" commits_case accept "forge committer on a human-authored commit" +# --- A configured automation name is a machine too (iss-2609090951276167) ----- +# The `[bot]` suffix is stamped by the forge, so an automation committing under a +# name it was CONFIGURED with slipped past every list: semantic-release-bot at a +# forge no-reply address matched no AI name, no AI domain, no `[bot]`, and no +# author-only no-reply mailbox, and was judged a human. The widened signal is a +# trailing bot/robot/automation word, standing alone, ending the name or the +# mailbox's local part. +commit_as 'semantic-release-bot' '12345+semantic-release-bot@users.noreply.github.com' \ + 'semantic-release-bot' '12345+semantic-release-bot@users.noreply.github.com' "$MSG_OK" +commits_case reject "configured automation name at a forge no-reply address" + +commit_as 'Renovate Bot' 'renovate@example.invalid' \ + REPPL human@example.invalid "$MSG_OK" +commits_case reject "automation display name ending in the word Bot" + +commit_as 'Release' 'release_automation@example.invalid' \ + REPPL human@example.invalid "$MSG_OK" +commits_case reject "automation word ending the mailbox local part" + +commit_as REPPL human@example.invalid 'ci-robot' 'ci@example.invalid' "$MSG_OK" +commits_case reject "configured automation committer on a human-authored commit" + +# The word must stand alone: names and addresses that merely END in the letters +# are people's, and a dot in the local part is a person's ordinary separator. +commit_as 'Ada Talbot' '1234+talbot@users.noreply.github.com' \ + 'Ada Talbot' '1234+talbot@users.noreply.github.com' "$MSG_OK" +commits_case accept "human surname ending in the letters bot" + +commit_as 'Jean Abbott' 'jean.bot@example.invalid' \ + 'Jean Abbott' 'jean.bot@example.invalid' "$MSG_OK" +commits_case accept "human whose local part ends .bot" + # --- Merge commits carry an identity too (iss-2609082001204831) ---------------- # The first hole: the commits arm walked `--no-merges`, so a merge commit's # identity was never read at all. 23f0a891 stands in main today, authored AND diff --git a/scripts/check-attribution.sh b/scripts/check-attribution.sh index bf9ad5fed..4aac3d40b 100755 --- a/scripts/check-attribution.sh +++ b/scripts/check-attribution.sh @@ -136,7 +136,8 @@ AI_IDENT_MAIL_RE='@anthropic\.com$|@openai\.com$' # So these two are STRUCTURAL rather than nominal: the `[bot]` suffix the forge # itself stamps on an app's account name, and the mailbox shape it stamps on the # address — `49699333+dependabot[bot]@users.noreply.github.com`, or the older -# `name[bot]@…`. A second automation lands in the right place with no edit here. +# `name[bot]@…`. A second automation THE FORGE STAMPS lands in the right place +# with no edit here; one it does not stamp is the next rule's. # # THE MAILBOX IS THE DISCRIMINATOR, NEVER THE HOST, and this is the line to read # twice before touching it. `1234+name@users.noreply.github.com` is a PERSON'S @@ -153,6 +154,34 @@ AI_IDENT_MAIL_RE='@anthropic\.com$|@openai\.com$' MACHINE_NAME_RE='\[bot\][[:space:]]*$' MACHINE_MAIL_RE='\[bot\]@|@dependabot\.com$' +# The forge's suffix is only the machines THE FORGE stamps. An automation that +# commits under a name it was configured with — semantic-release-bot at a forge +# no-reply address, a self-hosted CI account, a forge whose app suffix is not +# `[bot]` — matched none of the lists above and was judged a human +# (iss-2609090951276167). So the structural signal is widened to the SHAPE such +# configured names take: a trailing `bot`, `robot` or `automation` word, ending +# the display name or the mailbox's local part. +# +# The word must stand alone: at the start of the field, or after a separator. +# Talbot and Abbott pass; semantic-release-bot, ci_bot, `Renovate Bot` and +# `12345+semantic-release-bot@users.noreply.github.com` do not. In the local +# part the separators are `-`, `_` and the forge's `+`, and deliberately not +# `.`: `jean.bot@` is the ordinary shape of a person's address. In the display +# name whitespace separates too, which is the one stated over-reach — a person +# whose name's last word is Bot is refused, loudly and naming the identity — +# accepted because the failure it prevents is silent, the reason this rule +# exists. This is the line to revisit if such a contributor arrives. +# +# OUT OF REACH, said plainly: a machine whose configured name and mailbox look +# like a person's (`Release Manager `), a trailing word +# other than these three (`-ci`, `-agent`), and `name.bot@` addresses. Nothing +# structural separates those from a human; the reviewer reading the identity +# is the check on them. internal/core/site/contributors.go's machineAddrRe does +# not share this signal: the contributors page reads the published history, not +# a pull request's range, and refusing is this gate's job alone. +MACHINE_NAME_WORD_RE='(^|[-_[:space:]])(bot|robot|automation)[[:space:]]*$' +MACHINE_LOCAL_WORD_RE='(^|[-_+])(bot|robot|automation)@' + # A mailbox literally named for not being read. Refused in the AUTHOR role only, # and the asymmetry is load-bearing rather than a hedge: `GitHub # ` is the COMMITTER of every merge and squash made through @@ -464,7 +493,9 @@ check_ident() { grep -Eiq "$AI_IDENT_MAIL_RE" <<<"$mail"; then kind="an AI" elif grep -Eiq "$MACHINE_NAME_RE" <<<"$name" || - grep -Eiq "$MACHINE_MAIL_RE" <<<"$mail"; then + grep -Eiq "$MACHINE_MAIL_RE" <<<"$mail" || + grep -Eiq "$MACHINE_NAME_WORD_RE" <<<"$name" || + grep -Eiq "$MACHINE_LOCAL_WORD_RE" <<<"$mail"; then kind="a machine" elif [ "$role" = author ] && grep -Eiq "$AUTHOR_ONLY_MAIL_RE" <<<"$mail"; then kind="a machine" From 224ead9f714630263cc6a1567fa775353edb421e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:21:25 +0100 Subject: [PATCH 41/78] =?UTF-8?q?chore:=20resolve=20iss-2609281314564762?= =?UTF-8?q?=20=E2=80=94=20grep=20-q=20reads=20a=20here-string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609281314564762 Assisted-by: Claude:claude-opus-5-5 --- ...-two-ci-gate-scripts-test-membership-and-matches-by.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md (68%) diff --git a/.abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md b/.abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md similarity index 68% rename from .abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md rename to .abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md index 286c101c1..b79cc1235 100644 --- a/.abcd/work/issues/open/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md +++ b/.abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25 (lane drainScr, full-history origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" +resolution: "Every membership and match test in scripts/check-issue-resolution.sh and scripts/check-attribution.sh reads a here-string instead of a printf pipe, and RS006's frontmatter reader reads to the end instead of exiting early; the cases suites' own helpers take the same change. Proved by two cases, each failing against the previous scripts: a message declaring 1,100 long ids (about 70 KiB) is read whole by RS004 (the previous gate refused 39 of its declared ids), and a Co-authored-by line at the top of a 200 KiB pull-request body is refused (the previous gate accepted it)." +impact: internal +resolved_by: + commit: "5b7c79426" --- The two CI gate scripts test membership and matches by piping printf into grep -q under set -o pipefail, and that pipeline is a race whenever printf needs more than one write: grep -q exits at its first match, printf's next write takes SIGPIPE, the pipeline returns 141, and the test reads as no match. Under load the race loses at a few KiB; past one pipe buffer (64 KiB) it loses every time. Measured: a 50 KiB id list missed 0 of 30 runs and a 70 KiB one 30 of 30; a here-string missed none. In scripts/check-issue-resolution.sh the RS001 membership test against the ids entering a terminal folder, RS004's declared-id test, and RS005's shipped test all take this shape, so two full-history runs of the gate over the same range at the same commits differed by 47 violations (39 RS001 refusals of records that did enter resolved/, seven RS004 refusals of ids a 190-line Refs: block declared, one RS005), and the base-listing membership test the lane adds for iss-2609012047551175 would read a record terminal at the base as absent on a real ledger, failing open. In scripts/check-attribution.sh check_text pipes a whole commit message or pull-request body into grep -q for the tool-footer, Co-authored-by and trailer checks, so a body longer than the buffer whose banned line is matched before the last write can pass. Fix: feed grep from a here-string, which bash writes in full before grep reads, so no writer is left to take SIGPIPE. + +## Grounds + +- pursued: the gates give the same verdict on the same range whatever the machine load or input size; two full-history runs at the same commits disagreeing again would show it wrong From 36777d09120a4fb4846e37f9ff3735d3badf257b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:21:27 +0100 Subject: [PATCH 42/78] =?UTF-8?q?chore:=20resolve=20iss-2609090951276167?= =?UTF-8?q?=20=E2=80=94=20configured=20automation=20names=20are=20machines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609090951276167 Assisted-by: Claude:claude-opus-5-5 --- ...ttribution-machine-signal-is-bot-suffix-shaped-only.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md (66%) diff --git a/.abcd/work/issues/open/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md b/.abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md similarity index 66% rename from .abcd/work/issues/open/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md rename to .abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md index 5425fb5e6..b889cae34 100644 --- a/.abcd/work/issues/open/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md +++ b/.abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md @@ -9,6 +9,14 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-attribution.sh" +resolution: "The attribution gate also refuses, in both roles, a trailing bot, robot or automation word standing alone at the end of the display name or the mailbox's local part, so a configured automation such as semantic-release-bot at a forge no-reply address is a machine; the comment names the shapes still out of reach and the one over-reach (a person whose name ends in the separate word Bot). Proved by cases in scripts/check-attribution-cases.sh: semantic-release-bot at a forge no-reply address, Renovate Bot, release_automation@ and a ci-robot committer are refused (all accepted by the previous gate), while Ada Talbot at a forge privacy address and jean.bot@ still pass." +impact: internal +resolved_by: + commit: "bd3f55e68" --- The attribution gate states its rule as refuse machines and allow humans, and implements the machine half structurally rather than nominally: a name ending in the forge-stamped bot suffix, a mailbox carrying that suffix, or one vendor domain. Its comment claims a second automation lands in the right place with no edit here, which holds only for automations the forge itself stamps. An identity such as semantic-release-bot with a forge no-reply address matches neither the AI name list nor the AI mail list, neither machine pattern, and not the author-only no-reply rule, so it is judged a human and the gate exits clean; verified by reading the four patterns against that identity. Self-hosted release automation, CI bots committing under a plain configured name, and any forge whose suffix is not the one hard-coded here all land the same way, and this gate is the only thing standing between them and the contributor graph the rule exists to protect. It matters because the rule was written after a bot walked past the nominal list, and the structural replacement inherits the same enumeration in a different alphabet. Fix direction: widen the structural signal beyond one forge suffix, whether by treating a trailing bot or automation token in the name or local part as machine-shaped, by keeping an automation mailbox list beside it, or by requiring a positive human signal, and say in the comment which shapes remain out of reach. Detector: a commit authored as semantic-release-bot with a forge no-reply address must be refused as a machine, while an outside human contributor with a forge privacy address still passes. + +## Grounds + +- pursued: a self-configured automation identity is refused while outside humans at forge privacy addresses still pass; a full-history run refusing a human commit the previous gate passed would show it wrong From ab69c1e92e58db7fa94dcdc009897db7789f5819 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:21:35 +0100 Subject: [PATCH 43/78] chore: defer iss-2609221820487644 out loud past v0.11.0 The record asks for a dependency bump to land without a human re-authoring it. Its own 2026-09-22 ruling keeps the attribution gate unchanged and routes the bump through a re-authoring workflow (itd-2609221842494980), and AGENTS.md states the standing rule that a dependabot pull request is not mergeable as authored. That workflow is blocked on a ruling owed to the product thinker (section L of the run's rulings-owed list: which token the re-authoring push uses, since a push made with the built-in CI token starts no checks, and whether automated dependency updates are wanted at all). Nothing in the gate script changes for this record, so it is deferred with that reason rather than built. Refs: iss-2609221820487644 Assisted-by: Claude:claude-opus-5-5 --- ...t-dependency-bump-through-without-a-human-re-authoring-it.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.abcd/work/issues/open/iss-2609221820487644-the-attribution-gate-should-let-a-dependabot-dependency-bump-through-without-a-human-re-authoring-it.md b/.abcd/work/issues/open/iss-2609221820487644-the-attribution-gate-should-let-a-dependabot-dependency-bump-through-without-a-human-re-authoring-it.md index 02d7f3b67..e8a089036 100644 --- a/.abcd/work/issues/open/iss-2609221820487644-the-attribution-gate-should-let-a-dependabot-dependency-bump-through-without-a-human-re-authoring-it.md +++ b/.abcd/work/issues/open/iss-2609221820487644-the-attribution-gate-should-let-a-dependabot-dependency-bump-through-without-a-human-re-authoring-it.md @@ -9,6 +9,8 @@ found_during: "PR 655 blocked on 2026-09-22; the bump landed by hand as PR 659" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-attribution.sh (check_ident); AGENTS.md, Attribution and acknowledgements" +deferred_after: "v0.11.0" +deferral_reason: "ruling owed to the product thinker (deferred by lane drainScr of autonomous run A, 2026-09-28): the standing ruling in AGENTS.md is that a dependabot pull request is not mergeable as authored and a dependency bump is landed by a human, and the 2026-09-22 ruling below keeps the gate unchanged and routes the bump through a re-authoring workflow (itd-2609221842494980). That workflow is blocked on the rulings-owed list, section L: a push made with the built-in CI token starts no checks, so the re-authored commit would never be checked, and the token choice (a personal access token or an app token kept as a secret) and whether automated dependency updates are switched on at all are the product thinker's. Nothing in scripts/check-attribution.sh changes for this record." --- The attribution gate should let a dependabot dependency bump through without a human re-authoring it. The product thinker asked for this on 2026-09-22 after PR 655 sat blocked with auto-merge armed and every other check green. Two things must be said precisely, because the request names the trailer and the trailer is not what fails. The gate refuses a bot on IDENTITY: check_ident reads dependabot[bot] as a machine in the author role on two independent signals, the forge's [bot] name suffix and the bot mailbox, and it deliberately stays silent about the missing Assisted-by trailer so the remedy is not misread, its own comment saying that adding a trailer is not the fix for a dependency bump and landing it as a human is. No review clears an identity refusal, which is why an armed auto-merge looks like it is waiting for a reviewer when it is waiting for something no reviewer can give. The rule is deliberate and stated in AGENTS.md: the contributor graph is built from the author and committer fields, a machine there asserts an authorship it does not hold, and a squash merge re-appends a mis-identified branch author as a co-author, so the consequence, that a dependabot pull request is not mergeable as authored, is written down as intended. Wanted: a way for a dependency bump to land without a person re-authoring it every time, without letting a machine into the contributor graph on any other change. Candidate shapes for the decision to weigh: the gate exempts a commit whose diff touches only go.mod and go.sum on a branch the forge marks as dependabot's, with the exemption named in the refusal it would otherwise raise; or the repository takes dependabot's bumps through a workflow that re-authors them as the human owner before the gate runs, so nothing about the gate changes; or dependabot is turned off for this repository and bumps are made by hand on a schedule. This reverses a stated rule, so it is an ADR plus a brief invariant before any code moves, not a quiet edit to the script. From fc4ad57f0587b4d72f3657823227bdd2b36cd33e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:29:04 +0100 Subject: [PATCH 44/78] =?UTF-8?q?chore:=20capture=20iss-2609281329007423?= =?UTF-8?q?=20=E2=80=94=20a=20path=20outside=20HOME=20prints=20whole=20thr?= =?UTF-8?q?ough=20RedactHome=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: iss-2609281329007423 Assisted-by: Claude:claude-opus-5-5 --- ...rktree-or-store-path-outside-home-is-printed.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md diff --git a/.abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md b/.abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md new file mode 100644 index 000000000..e5b624cb2 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609281329007423" +slug: "a-checkout-worktree-or-store-path-outside-home-is-printed" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainCap" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/fsutil/paths.go" +--- + +A checkout, worktree or store path outside HOME is printed whole by the surfaces that redact through fsutil.RedactHome alone: RedactHome turns a path under HOME into ~/rel and leaves every other absolute path untouched, so a sibling worktree in /private/tmp or on another volume reaches abcd peers (--json path, not_read, skipped, the peer-held refusal), the implement check's contention detail, history ingest's destination and orphan cwd, and implement status's lane worktree as a full local path in output a person pastes elsewhere. The display rule that fixes it (under HOME the home-relative form, outside HOME the directory's base name) is stated inline twice, in scrubPaths and in the capture verbs' ledgerIdentityOf, and in no primitive a new surface can reach, which is why each new surface re-derives RedactHome alone. From 1d334d124adb0ed01593dc74317d7772663fa812 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:39:40 +0100 Subject: [PATCH 45/78] fix(fsutil): one display rule for a directory a surface names RedactHome turns a path under HOME into ~/rel and leaves every other absolute path whole, so a checkout or worktree outside HOME reached peers, the implement peers check, history ingest and implement status as an absolute local path. The rule that fixes it (home-relative under HOME, the directory's base name outside it) was stated inline twice, in scrubPaths and in the capture verbs' ledgerIdentityOf, and in no primitive a new surface could reach. fsutil.DisplayPath states it once, and DisplayPathsIn applies it inside a message that carries a known path (a peer's not-read reason, an error's embedded paths). scrubPaths and ledgerIdentityOf route through them, and so do the confirmed sites: peers' path, not_read, skipped path and peer-held refusal; the implement peers check's worktree name and not-read reason; history ingest's destination refusals, orphan cwd, report line and --into refusal; and implement status's lane worktree. TestNoInlineBaseNameDisplayRule refuses a new inline IsAbs-then-Base display outside fsutil. Kept on RedactHome, because the reader acts on the path: an await's brief and receipt (the agent reads one and passes the other to implement receipt), the sources corpus the person names with --corpus, the history store move note and tombstone, and ahoy's meta.json corpus pointer; the run directory sits under HOME by construction. Refs: iss-2609281329007423 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/08-abcd.md | 5 +- .../brief/04-surfaces/11-history.md | 3 +- .../development/brief/04-surfaces/34-build.md | 5 +- commands/build.md | 4 +- commands/history.md | 4 +- commands/peers.md | 5 +- docs/reference/cli/commands.md | 5 +- internal/core/history/ingest.go | 9 +- internal/core/history/ingest_test.go | 34 ++++++ internal/core/history/location.go | 2 + internal/core/implement/loop/check.go | 8 +- internal/core/implement/loop/loop_test.go | 51 ++++++++ internal/fsutil/displaypath_test.go | 115 ++++++++++++++++++ internal/fsutil/paths.go | 41 +++++++ internal/surface/cli/build.go | 7 +- internal/surface/cli/build_surface_test.go | 50 ++++++++ internal/surface/cli/cli.go | 28 ++--- internal/surface/cli/history_recovery.go | 4 +- internal/surface/cli/history_recovery_test.go | 45 +++++++ internal/surface/cli/implement.go | 2 + internal/surface/cli/peers.go | 17 +-- internal/surface/cli/peers_surface_test.go | 93 ++++++++++++++ internal/surface/cli/source.go | 5 +- 23 files changed, 495 insertions(+), 47 deletions(-) create mode 100644 internal/fsutil/displaypath_test.go diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index cab851983..a1d03fb0b 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -142,8 +142,9 @@ and one with no records at the committed layout is named with the reason and not read; when the worktree is gone or git refuses it, its branch is read from the object store instead, so a dead worktree never hides an unmerged commit. The board carries one `peers:` line (JSON `peers`: `live`, `ids`) only when some peer holds a record that differs here; `abcd peers` prints the -whole picture, as text or in its JSON form, with every home path redacted to -`~`. The same reader answers the not-found paths of `abcd `, of +whole picture, as text or in its JSON form, with every worktree named +home-relative, or by its directory name outside HOME, never by an absolute +path (iss-2609281329007423). The same reader answers the not-found paths of `abcd `, of resolving a capture and of the intent audit, consulted only after the local lookup fails. It writes nothing, takes no lock and fetches nothing. diff --git a/.abcd/development/brief/04-surfaces/11-history.md b/.abcd/development/brief/04-surfaces/11-history.md index 51b91b8ab..fcaea7205 100644 --- a/.abcd/development/brief/04-surfaces/11-history.md +++ b/.abcd/development/brief/04-surfaces/11-history.md @@ -90,7 +90,8 @@ ahoy's registry stays under `~/.abcd/history/` and holds no transcripts. path. A transcript owned elsewhere is skipped and its owner named by root SHA; one recorded in two repositories is skipped rather than split. A transcript whose repository is not on this machine is an **orphan: ignored, reported, - never guessed**, and adopted only when this repository claims its project name + never guessed** (its recorded directory, like the destination, is shown + home-relative or by its directory name outside HOME, iss-2609281329007423), and adopted only when this repository claims its project name in `adopt_projects` or on the command line; an adopted record carries `adopted_project`. Setting `on_orphan` to `prompt` makes the CLI ask — core never prompts. Ingesting the same material twice adds nothing. diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index b44c89b3c..92eaf2608 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -96,7 +96,10 @@ default); the window clock the pacing intent writes (`window_started_at`, 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. +receipt and pull request. The status render names the worktree home-relative, +or by its directory name outside HOME (iss-2609281329007423); the brief and the +receipt stay whole paths, home-redacted, because the agent reads the one and +writes the other. 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 diff --git a/commands/build.md b/commands/build.md index f51e35a17..dacfa737c 100644 --- a/commands/build.md +++ b/commands/build.md @@ -75,7 +75,9 @@ returns hand the receipt back: 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. +lanes and its record, and writes nothing. A lane's `worktree` is home-relative, +or its directory name when it sits outside HOME; its `brief` and `receipt` keep +their full home-relative paths, because the agent acts on them. 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 diff --git a/commands/history.md b/commands/history.md index 951ff35a7..b6e8702a5 100644 --- a/commands/history.md +++ b/commands/history.md @@ -263,7 +263,9 @@ gone is placed by the session that spawned it. Report all four populations: root SHA), `orphans`, and `failed`. An **orphan** — a transcript whose repository is not on this machine — is -ignored and reported, never guessed at. It is stored only when this repository +ignored and reported, never guessed at; its recorded `cwd` is shown +home-relative, or by its directory name when it sits outside HOME, as is the +destination the report leads with. It is stored only when this repository claims its project name, through `adopt_projects` in the configuration or `--adopt` for one run, and an adopted record carries `adopted_project` so the adoption is on the artefact. When the configuration sets `on_orphan` to diff --git a/commands/peers.md b/commands/peers.md index 4bf5bd080..8187f4171 100644 --- a/commands/peers.md +++ b/commands/peers.md @@ -26,8 +26,9 @@ Run: The payload carries `live` (the live-peer count), `ids` (the distinct records across every row), `default_ref` (the branch a peer is judged merged into), `sources` (the sources read: `worktree` and `branch`), `peers` and `skipped`. -Each peer names its `source`, `branch`, `path` (home-redacted to `~`) and its -`rows`; each row is an `id`, a `kind`, the `folder` that holds it in the peer +Each peer names its `source`, `branch`, `path` (home-relative, `~/…`, or the +worktree's directory name when it sits outside HOME, never an absolute path) +and its `rows`; each row is an `id`, a `kind`, the `folder` that holds it in the peer and, when the file could be read, a `title`: - `open-there` — an issue open in the peer and absent from every status folder diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 5643377cf..0dacacc45 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -2230,8 +2230,9 @@ two status folders is named with the reason and not read; a gone or refused worktree's branch is then read from the object store instead. Strictly read-only: it writes nothing, takes no lock, and fetches nothing. -Home paths are redacted to ~ on every stream. Exit 0 whatever the peers -hold; exit 2 outside a git checkout. +A worktree is named home-relative (~/...), or by its directory name when it +sits outside HOME, on every stream. Exit 0 whatever the peers hold; exit 2 +outside a git checkout. ### `abcd reading` diff --git a/internal/core/history/ingest.go b/internal/core/history/ingest.go index 539fb0a59..a4225b103 100644 --- a/internal/core/history/ingest.go +++ b/internal/core/history/ingest.go @@ -119,11 +119,11 @@ func (d Destination) verify() error { sha, ok := resolveRootSHA(d.RepoRoot) if !ok || sha == "" { return fmt.Errorf("history: ingest cannot resolve the root commit of the destination repository at %s, so it cannot prove that root owns the store key %s; name a git repository with commits as the destination", - fsutil.RedactHome(d.RepoRoot), d.RootSHA) + fsutil.DisplayPath(d.RepoRoot), d.RootSHA) } if sha != d.RootSHA { return fmt.Errorf("history: ingest destination is inconsistent — the repository at %s has root commit %s, not the store key %s; the pair must name ONE repository, because the scanner is built from the root and the records are filed under the key, and a mismatch redacts under one repository's configuration while filing into another's corpus", - fsutil.RedactHome(d.RepoRoot), sha, d.RootSHA) + fsutil.DisplayPath(d.RepoRoot), sha, d.RootSHA) } return nil } @@ -185,7 +185,8 @@ type Orphan struct { Project string `json:"project"` SessionID string `json:"session_id,omitempty"` AgentID string `json:"agent_id,omitempty"` - // Cwd is the working directory the transcript recorded, home-redacted. + // Cwd is the working directory the transcript recorded, as fsutil.DisplayPath + // shows it: home-relative, or its directory name outside HOME. Cwd string `json:"cwd,omitempty"` } @@ -390,7 +391,7 @@ func ingestOne(dest Destination, opts IngestOptions, p transcriptProbe, placed s if _, claimed := adopt[p.project]; !claimed { res.Orphans = append(res.Orphans, Orphan{ Path: p.path, Project: p.project, SessionID: p.sessionID, - AgentID: p.agentID, Cwd: fsutil.RedactHome(p.firstCwd()), + AgentID: p.agentID, Cwd: fsutil.DisplayPath(p.firstCwd()), }) return } diff --git a/internal/core/history/ingest_test.go b/internal/core/history/ingest_test.go index d5188f02a..ce5f09918 100644 --- a/internal/core/history/ingest_test.go +++ b/internal/core/history/ingest_test.go @@ -443,3 +443,37 @@ func TestIngestRefusesADestinationRootThatIsNoRepository(t *testing.T) { t.Errorf("the refusal must say what it could not resolve: %v", err) } } + +// TestIngestNamesADirectoryOutsideHomeByItsBaseName: the destination refusals +// and an orphan's recorded directory named a path through the home redaction +// alone, so a repository or working directory outside HOME reached them as an +// absolute local path (iss-2609281329007423). Each names it by its directory +// name instead. +func TestIngestNamesADirectoryOutsideHomeByItsBaseName(t *testing.T) { + repoRoot, _ := setupStore(t) + outside := t.TempDir() + unresolved := filepath.Join(outside, "dest-unresolved") + mismatched := filepath.Join(outside, "dest-mismatched") + orphanCwd := filepath.Join(outside, "orphan-cwd") + fakeRepos(t, map[string]string{repoRoot: testRootSHA, mismatched: otherRootSHA}) + + for root, name := range map[string]string{unresolved: "dest-unresolved", mismatched: "dest-mismatched"} { + _, err := Ingest(Destination{RepoRoot: root, RootSHA: testRootSHA}, []string{t.TempDir()}, IngestOptions{}) + if err == nil { + t.Fatalf("Ingest accepted the destination %s", name) + } + if !strings.Contains(err.Error(), "repository at "+name) || strings.Contains(err.Error(), outside) { + t.Errorf("the refusal must name the destination by its directory name %q, got %q", name, err) + } + } + + src := t.TempDir() + transcriptFile(t, filepath.Join(src, "some-project"), "s1.jsonl", "sess-orphan", "", orphanCwd) + res, err := Ingest(Destination{RepoRoot: repoRoot, RootSHA: testRootSHA}, []string{src}, IngestOptions{}) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(res.Orphans) != 1 || res.Orphans[0].Cwd != "orphan-cwd" { + t.Fatalf("want one orphan whose directory is named orphan-cwd, got %+v", res.Orphans) + } +} diff --git a/internal/core/history/location.go b/internal/core/history/location.go index a030d3c41..0f19e3207 100644 --- a/internal/core/history/location.go +++ b/internal/core/history/location.go @@ -329,6 +329,8 @@ func migrateLegacy(home, rootSHA string, dst Resolution) string { if movedRecords == 0 && movedStaged == 0 && complete { return "" // the legacy dirs existed but were empty: nothing worth saying. } + // Store paths, not checkouts: the note is the one notice of where the corpus + // went, so both keep RedactHome rather than fsutil.DisplayPath's base name. note := fmt.Sprintf("history: moved %d transcript(s) and %d staged file(s) out of %s into %s", movedRecords, movedStaged, fsutil.RedactHome(legacyRepo), fsutil.RedactHome(dst.Base)) if !complete { diff --git a/internal/core/implement/loop/check.go b/internal/core/implement/loop/check.go index e73de84d8..154179eb6 100644 --- a/internal/core/implement/loop/check.go +++ b/internal/core/implement/loop/check.go @@ -308,7 +308,7 @@ func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { // 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)+")") + holders = append(holders, peerName(p.Source, p.Branch, p.Path)+" could not be read, so what it holds is unknown ("+fsutil.DisplayPathsIn(p.NotRead, p.Path)+")") } if sha := gitutil.RootCommit(repoRoot); gitutil.IsFullSHA(sha) { run, err := implement.Peek(sha) @@ -340,12 +340,14 @@ func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { return row, nil } -// peerName names a peer for a refusal. +// peerName names a peer for a refusal, a worktree by fsutil.DisplayPath so one +// outside HOME is its directory name, not an absolute local path +// (iss-2609281329007423). func peerName(src peers.Source, branch, path string) string { if src != peers.SourceWorktree { return "branch " + branch } - who := "the worktree at " + fsutil.RedactHome(path) + who := "the worktree at " + fsutil.DisplayPath(path) if branch != "" { who += " (branch " + branch + ")" } diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go index f5ce1e169..fa0ccf368 100644 --- a/internal/core/implement/loop/loop_test.go +++ b/internal/core/implement/loop/loop_test.go @@ -178,6 +178,57 @@ func TestStartRefusesAPeerHoldingTheRecord(t *testing.T) { }) } +// TestThePeerRefusalNamesAWorktreeOutsideHomeByItsDirectoryName: the refusal +// named a peer worktree through the home redaction alone, so one outside HOME +// reached the refusal as an absolute local path, in its name and inside its +// not-read reason (iss-2609281329007423). Both name it by its directory name. +func TestThePeerRefusalNamesAWorktreeOutsideHomeByItsDirectoryName(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + outside := t.TempDir() + held := filepath.Join(outside, "wt-held") + repo.Git("worktree", "add", "-q", "-b", "lane-alpha", held) + shipped := ".abcd/development/intents/shipped/itd-10-alpha.md" + if err := os.MkdirAll(filepath.Dir(filepath.Join(held, shipped)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Rename(filepath.Join(held, plannedRel), filepath.Join(held, shipped)); err != nil { + t.Fatal(err) + } + shut := filepath.Join(outside, "wt-shut") + repo.Git("worktree", "add", "-q", "-b", "lane-shut", shut) + if err := os.WriteFile(filepath.Join(shut, "note"), []byte("ahead\n"), 0o644); err != nil { + t.Fatal(err) + } + repo.Git("-C", shut, "add", "note") + repo.Git("-C", shut, "commit", "-q", "-m", "ahead of main") + locked := filepath.Join(shut, ".abcd", "development", "intents", "planned") + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + + _, 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) + } + for _, want := range []string{"the worktree at wt-held (branch lane-alpha)", "the worktree at wt-shut (branch lane-shut) could not be read", "wt-shut/"} { + if !strings.Contains(r.Reason, want) { + t.Errorf("the refusal lacks %q: %q", want, r.Reason) + } + } + abs := []string{outside} + if real, err := filepath.EvalSymlinks(outside); err == nil && real != outside { + abs = append(abs, real) + } + for _, a := range abs { + if strings.Contains(r.Reason, a) { + t.Errorf("the refusal prints the absolute worktree path under %s: %q", a, r.Reason) + } + } + runTierAbsent(t, repo.Root()) +} + // 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 diff --git a/internal/fsutil/displaypath_test.go b/internal/fsutil/displaypath_test.go new file mode 100644 index 000000000..507b3be57 --- /dev/null +++ b/internal/fsutil/displaypath_test.go @@ -0,0 +1,115 @@ +package fsutil_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// DisplayPath is the display rule for a directory a surface names: under HOME +// the home-relative form, outside HOME the base name, never the absolute path +// (iss-2609281329007423). +func TestDisplayPathNamesADirectoryOutsideHomeByItsBaseName(t *testing.T) { + base := t.TempDir() + home := filepath.Join(base, "home") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + outside := filepath.Join(base, "elsewhere", "wt-a") + for in, want := range map[string]string{ + filepath.Join(home, "wt", "a"): filepath.Join("~", "wt", "a"), + home: "~", + outside: "wt-a", + outside + string(filepath.Separator): "wt-a", + "relative/dir": "relative/dir", + "": "", + } { + if got := fsutil.DisplayPath(in); got != want { + t.Errorf("DisplayPath(%q) = %q, want %q", in, got, want) + } + } + if real, err := filepath.EvalSymlinks(home); err == nil && real != home { + if got := fsutil.DisplayPath(filepath.Join(real, "x")); got != filepath.Join("~", "x") { + t.Errorf("DisplayPath of the resolved home spelling = %q, want ~/x", got) + } + } +} + +// DisplayPathsIn applies the rule inside a message: the named path outside HOME +// becomes its base name wherever it starts a path, in either spelling, a longer +// path under it keeps its tail, and the same bytes inside a longer unrelated +// path are left alone. +func TestDisplayPathsInReducesANamedPathOutsideHome(t *testing.T) { + base := t.TempDir() + home := filepath.Join(base, "home") + realDir := filepath.Join(base, "real", "wt") + for _, d := range []string{home, realDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + link := filepath.Join(base, "link") + if err := os.Symlink(realDir, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + t.Setenv("HOME", home) + resolved, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatal(err) + } + + msg := "stat " + link + "/.git: denied; also " + resolved + " and /srv" + link + " and " + filepath.Join(home, "y") + got := fsutil.DisplayPathsIn(msg, link, "relative") + want := "stat link/.git: denied; also link and /srv" + link + " and " + filepath.Join("~", "y") + if got != want { + t.Fatalf("DisplayPathsIn =\n %q\nwant\n %q", got, want) + } +} + +// inlineBaseNameDisplayRe matches the display rule restated inline: an +// IsAbs test whose block's first statement takes filepath.Base — the shape +// ledgerIdentityOf and scrubPaths each carried before DisplayPath existed. +var inlineBaseNameDisplayRe = regexp.MustCompile(`filepath\.IsAbs\([^)]*\)\s*\{\s*[^\n]*filepath\.Base\(`) + +// TestNoInlineBaseNameDisplayRule is the one-canonical-primitive detector for +// the display rule: no non-test file under internal/ outside fsutil restates +// "absolute, so print the base name" inline. A surface that names a directory +// routes through fsutil.DisplayPath or DisplayPathsIn, so the rule has one +// statement and a new surface cannot get it half right (iss-2609281329007423). +func TestNoInlineBaseNameDisplayRule(t *testing.T) { + var offenders []string + err := filepath.WalkDir("..", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if filepath.Base(path) == "fsutil" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if inlineBaseNameDisplayRe.Match(data) { + offenders = append(offenders, path) + } + return nil + }) + if err != nil { + t.Fatalf("walk internal/: %v", err) + } + if len(offenders) > 0 { + t.Fatalf("the base-name display rule restated inline (route through fsutil.DisplayPath / DisplayPathsIn):\n %s", + strings.Join(offenders, "\n ")) + } +} diff --git a/internal/fsutil/paths.go b/internal/fsutil/paths.go index a8e450001..6f9562ef1 100644 --- a/internal/fsutil/paths.go +++ b/internal/fsutil/paths.go @@ -312,6 +312,47 @@ func RedactHome(s string) string { return s } +// DisplayPath is the one statement of how a surface prints a directory it names +// — a checkout, a worktree, a repository root: under HOME it is the +// home-relative form RedactHome gives ("~/rel", or "~" for HOME itself), and +// outside HOME it is the directory's base name. RedactHome alone leaves a path +// outside HOME whole, and printed whole it is an absolute local path in output a +// person pastes elsewhere (iss-2609281329007423). A relative or empty p is +// returned unchanged. +// +// It is for a path shown so a reader can recognise it, not one the reader must +// type to act: a store directory the person passed with a flag, a brief an +// agent is handed or a receipt it must write keep RedactHome, because a base +// name there would leave the reader unable to act on it. +func DisplayPath(p string) string { + if shown := RedactHome(p); !filepath.IsAbs(shown) { + return shown + } + return filepath.Base(p) +} + +// DisplayPathsIn is DisplayPath inside a message: s is home-redacted, and each +// absolute path in paths is replaced by its DisplayPath wherever it starts a +// path in s (RedactRoot's boundary rule), in both the spelling given and its +// symlink-resolved one (the one git and the kernel report back). A longer path +// under it keeps its tail ("/tmp/wt/.git" becomes "wt/.git"). It is for a +// reason or an error that carries a path the caller already knows, such as a +// peer's not-read reason naming the peer's directory. +func DisplayPathsIn(s string, paths ...string) string { + s = RedactHome(s) + for _, p := range paths { + if !filepath.IsAbs(p) { + continue + } + shown := DisplayPath(p) + s = RedactRoot(s, p, shown) + if real, err := filepath.EvalSymlinks(p); err == nil && real != p { + s = RedactRoot(s, real, shown) + } + } + return s +} + // isPathBoundary reports whether c cannot be part of a path segment, so a root // immediately followed by c is a whole path rather than a prefix of a longer one. func isPathBoundary(c byte) bool { diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index f3b349554..eaba7200c 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -142,7 +142,10 @@ func renderPending(w io.Writer, pending []loop.PendingStep) { fmt.Fprintf(w, " pending: spec step %s\n", strings.Join(parts, ", ")) } -// redactAwait home-redacts the paths an await carries, for a stream. +// redactAwait home-redacts the paths an await carries, for a stream. They keep +// RedactHome rather than fsutil.DisplayPath: the brief is the file the agent is +// handed and the receipt the path it writes and passes to `implement receipt`, +// so a base name would leave it unable to act on either. func redactAwait(a *loop.Await) *loop.Await { if a == nil { return nil @@ -186,7 +189,7 @@ func newImplementStatusCommand(asJSON *bool) *cobra.Command { 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) + runs[i].Lanes[j].Worktree = fsutil.DisplayPath(runs[i].Lanes[j].Worktree) } } return render(cmd.OutOrStdout(), *asJSON, implementStatusRuns{Runs: runs}, func(w io.Writer) { diff --git a/internal/surface/cli/build_surface_test.go b/internal/surface/cli/build_surface_test.go index 6a0eb6552..3ed7c7c65 100644 --- a/internal/surface/cli/build_surface_test.go +++ b/internal/surface/cli/build_surface_test.go @@ -230,3 +230,53 @@ func TestImplementStepWithoutARunIsRefused(t *testing.T) { } runDirAbsent(t, repo.Root()) } + +// TestImplementStatusNamesALaneWorktreeOutsideHomeByItsDirectoryName: status +// --json named a lane's worktree through the home redaction alone, so one +// outside HOME was printed as an absolute local path (iss-2609281329007423). +// It is named by its directory name. +func TestImplementStatusNamesALaneWorktreeOutsideHomeByItsDirectoryName(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)) + raw, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + var state map[string]any + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "lane-wt") + state["lanes"].([]any)[0].(map[string]any)["worktree"] = outside + raw, err = json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(statePath, raw, 0o644); err != nil { + t.Fatal(err) + } + + out := mustImplement(t, "implement", "status", "--json") + var st struct { + Runs []struct { + Lanes []struct { + Worktree string `json:"worktree"` + } `json:"lanes"` + } `json:"runs"` + } + if err := json.Unmarshal([]byte(out), &st); err != nil || len(st.Runs) != 1 || len(st.Runs[0].Lanes) != 1 { + t.Fatalf("status --json = %v: %s", err, out) + } + if got := st.Runs[0].Lanes[0].Worktree; got != "lane-wt" { + t.Errorf("the lane worktree is shown as %q, want its directory name lane-wt", got) + } + if strings.Contains(out, filepath.Dir(outside)) { + t.Errorf("status --json prints the absolute worktree path:\n%s", out) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 59f29a318..c08b6ddcd 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3826,10 +3826,10 @@ type ledgerIdentity struct { // branch git reports ("HEAD" when detached, "" when git cannot answer). // // A checkout outside HOME survives RedactHome whole, and printed whole it is an -// absolute local path in output a person pastes elsewhere. It is reduced to its -// directory name instead, the rule scrubPaths already applies to an absolute -// path outside both identity roots, so the two surfaces agree on what is safe -// to print. +// absolute local path in output a person pastes elsewhere. fsutil.DisplayPath +// reduces it to its directory name instead, the rule scrubPaths applies to an +// absolute path outside both identity roots, so the two surfaces agree on what +// is safe to print. func ledgerIdentityOf(root string) ledgerIdentity { // symbolic-ref answers on an unborn branch too, where rev-parse cannot; it // fails only when HEAD is detached, which rev-parse then names. @@ -3841,11 +3841,7 @@ func ledgerIdentityOf(root string) ledgerIdentity { branch = "" } } - checkout := fsutil.RedactHome(root) - if filepath.IsAbs(checkout) { - checkout = filepath.Base(root) - } - return ledgerIdentity{Checkout: checkout, Branch: branch} + return ledgerIdentity{Checkout: fsutil.DisplayPath(root), Branch: branch} } // branchPhrase renders the branch half of the identity line. @@ -5727,6 +5723,10 @@ func newErrorEnvelope(msg string, code int) errorEnvelope { // - any remaining absolute path embedded by os.PathError/os.LinkError (e.g. a // path argument outside both roots) is reduced to its base name. // +// The home redaction and the base-name rule are fsutil.DisplayPathsIn, the one +// statement of how a surface prints a path it names (iss-2609281329007423); the +// working directory is redacted first so a path under it reads "./…", not "~/…". +// // This is NOT a universal absolute-path scrub: a verb that echoes a user-supplied // absolute path lying outside both roots (e.g. `memory ingest /tmp/x`) still // surfaces it — that path carries no developer identity, and sanitising such @@ -5743,15 +5743,7 @@ func scrubPaths(err error) string { if cwd, e := os.Getwd(); e == nil { msg = fsutil.RedactRoot(msg, cwd, ".") } - if home, e := os.UserHomeDir(); e == nil { - msg = fsutil.RedactRoot(msg, home, "~") - } - for _, p := range embeddedPaths(err) { - if filepath.IsAbs(p) { - msg = strings.ReplaceAll(msg, p, filepath.Base(p)) - } - } - return msg + return fsutil.DisplayPathsIn(msg, embeddedPaths(err)...) } // embeddedPaths collects the filesystem paths carried by os.PathError/os.LinkError diff --git a/internal/surface/cli/history_recovery.go b/internal/surface/cli/history_recovery.go index 28fb725cc..57572007a 100644 --- a/internal/surface/cli/history_recovery.go +++ b/internal/surface/cli/history_recovery.go @@ -220,7 +220,7 @@ func resolveDestination(into string) (history.Destination, error) { } if det.RootSHA == "" { return history.Destination{}, fmt.Errorf("history ingest: %s is not a git repository with commits, so it has no store key", - termsafe.Sanitize(fsutil.RedactHome(abs))) + termsafe.Sanitize(fsutil.DisplayPath(abs))) } return history.Destination{RepoRoot: abs, RootSHA: det.RootSHA}, nil } @@ -269,7 +269,7 @@ func renderHistoryIngest(w io.Writer, verb string, dest history.Destination, res } } fmt.Fprintf(w, "abcd history %s — into %s (root %s)\n", - verb, termsafe.Sanitize(fsutil.RedactHome(dest.RepoRoot)), dest.RootSHA) + verb, termsafe.Sanitize(fsutil.DisplayPath(dest.RepoRoot)), dest.RootSHA) fmt.Fprintf(w, " stored %d of %d owned transcript(s); %d skipped, %d orphaned, %d failed\n", wrote, len(res.Captured), len(res.Skipped), len(res.Orphans), len(res.Failed)) for _, c := range res.Captured { diff --git a/internal/surface/cli/history_recovery_test.go b/internal/surface/cli/history_recovery_test.go index 9ffd0a3c2..af837905f 100644 --- a/internal/surface/cli/history_recovery_test.go +++ b/internal/surface/cli/history_recovery_test.go @@ -315,3 +315,48 @@ func TestHistoryIngestRefusesWithoutAnExplicitDestination(t *testing.T) { t.Errorf("the refusal must name the flag that answers it, got: %v", runErr) } } + +// TestHistoryIngestNamesADestinationOutsideHomeByItsDirectoryName: the report's +// destination line and the not-a-repository refusal named --into through the +// home redaction alone, so a repository outside HOME was printed as an absolute +// local path (iss-2609281329007423). Both name it by its directory name. +func TestHistoryIngestNamesADestinationOutsideHomeByItsDirectoryName(t *testing.T) { + sessionEndRepo(t) + repo, _ := secondRepo(t) + t.Chdir(repo) + if home, err := os.UserHomeDir(); err != nil || strings.HasPrefix(repo, home) { + t.Fatalf("the fixture repository must sit outside HOME (%v)", err) + } + abs := []string{repo} + if r, err := filepath.EvalSymlinks(repo); err == nil && r != repo { + abs = append(abs, r) + } + + out, _, runErr := runRecovery("", "history", "ingest", "--into", repo, t.TempDir()) + if runErr != nil { + t.Fatalf("history ingest: %v\n%s", runErr, out) + } + if !strings.Contains(out, "into "+filepath.Base(repo)+" (root ") { + t.Errorf("the report must name the destination by its directory name, got:\n%s", out) + } + + notRepo := filepath.Join(t.TempDir(), "not-a-repo") + if err := os.Mkdir(notRepo, 0o755); err != nil { + t.Fatal(err) + } + _, _, runErr = runRecovery("", "history", "ingest", "--into", notRepo, t.TempDir()) + if runErr == nil || !strings.Contains(runErr.Error(), "history ingest: not-a-repo is not a git repository") { + t.Errorf("the refusal must name the destination by its directory name, got: %v", runErr) + } + refusal := "" + if runErr != nil { + refusal = runErr.Error() + } + for _, a := range append(abs, filepath.Dir(notRepo)) { + for what, s := range map[string]string{"report": out, "refusal": refusal} { + if strings.Contains(s, a) { + t.Errorf("the %s prints the absolute path %s:\n%s", what, a, s) + } + } + } +} diff --git a/internal/surface/cli/implement.go b/internal/surface/cli/implement.go index 87eaa652f..4d9640357 100644 --- a/internal/surface/cli/implement.go +++ b/internal/surface/cli/implement.go @@ -122,6 +122,8 @@ type implementStatusOutput struct { // implementStatus reads the run state for the bare render. func implementStatus(run *implement.Run) (implementStatusOutput, error) { + // The run directory is ~/.abcd/runs/, under HOME by construction, + // and a store the reader opens, so it keeps RedactHome, not fsutil.DisplayPath. out := implementStatusOutput{Dir: fsutil.RedactHome(run.Dir)} var err error if out.Sessions, err = run.Sessions(); err != nil { diff --git a/internal/surface/cli/peers.go b/internal/surface/cli/peers.go index aab4f1a91..2305fde77 100644 --- a/internal/surface/cli/peers.go +++ b/internal/surface/cli/peers.go @@ -49,8 +49,9 @@ func newPeersCommand(asJSON *bool) *cobra.Command { "two status folders is named with the reason and not read; a gone or refused\n" + "worktree's branch is then read from the object store instead.\n\n" + "Strictly read-only: it writes nothing, takes no lock, and fetches nothing.\n" + - "Home paths are redacted to ~ on every stream. Exit 0 whatever the peers\n" + - "hold; exit 2 outside a git checkout.", + "A worktree is named home-relative (~/...), or by its directory name when it\n" + + "sits outside HOME, on every stream. Exit 0 whatever the peers hold; exit 2\n" + + "outside a git checkout.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { cwd, err := os.Getwd() @@ -71,17 +72,19 @@ func newPeersCommand(asJSON *bool) *cobra.Command { } } -// peersView is the report as a surface shows it: paths and reasons redacted. +// peersView is the report as a surface shows it: paths and reasons redacted, +// each worktree named by fsutil.DisplayPath, so one outside HOME is its +// directory name rather than an absolute local path (iss-2609281329007423). func peersView(rep peers.Report) peersOutput { out := peersOutput{Sources: rep.Sources, DefaultRef: rep.DefaultRef, Live: rep.Live(), IDs: rep.IDCount(), Peers: make([]peers.Peer, 0, len(rep.Peers)), Skipped: make([]peers.Skipped, 0, len(rep.Skipped))} for _, p := range rep.Peers { - p.Path = fsutil.RedactHome(p.Path) - p.NotRead = fsutil.RedactHome(p.NotRead) + p.NotRead = fsutil.DisplayPathsIn(p.NotRead, p.Path) + p.Path = fsutil.DisplayPath(p.Path) out.Peers = append(out.Peers, p) } for _, s := range rep.Skipped { - s.Path = fsutil.RedactHome(s.Path) + s.Path = fsutil.DisplayPath(s.Path) out.Skipped = append(out.Skipped, s) } return out @@ -228,7 +231,7 @@ func peerHeldRefusal(cwd, prefix, id string, err error) error { h = "a detached worktree" } if l.Path != "" { - h += " at " + termsafe.Sanitize(fsutil.RedactHome(l.Path)) + h += " at " + termsafe.Sanitize(fsutil.DisplayPath(l.Path)) } else { h += " (checked out nowhere)" } diff --git a/internal/surface/cli/peers_surface_test.go b/internal/surface/cli/peers_surface_test.go index 2b257c6d8..e15a3b1fe 100644 --- a/internal/surface/cli/peers_surface_test.go +++ b/internal/surface/cli/peers_surface_test.go @@ -279,3 +279,96 @@ func TestTheScanBeforeMutatingConventionNamesThePeerListing(t *testing.T) { } } } + +// A sibling worktree outside HOME survives the home redaction whole, so peers +// printed it as an absolute local path — its path, its not-read reason, a spent +// worktree's path and the peer-held refusal alike (iss-2609281329007423). Each +// names the worktree by its directory name instead, fsutil.DisplayPath's rule. +func TestPeersNamesAWorktreeOutsideHomeByItsDirectoryName(t *testing.T) { + home, repo := peerCheckout(t) + outside := t.TempDir() + live := filepath.Join(outside, "wt-live") + gitCmd(t, repo, "worktree", "add", "-q", "-b", "feat/live", live, "main") + writeRel(t, live, ".abcd/work/issues/open/iss-100-a-peer-finding.md", peerIssue("iss-100", "a-peer-finding", "A finding the peer captured")) + shut := filepath.Join(outside, "wt-shut") + gitCmd(t, repo, "worktree", "add", "-q", "-b", "feat/shut", shut, "main") + writeRel(t, shut, ".abcd/work/issues/open/iss-101-another.md", peerIssue("iss-101", "another", "Another")) + // Committed, so the branch is ahead of main and its folders are read, not + // judged spent; the unreadable folder then fails that read with its path. + gitCmd(t, shut, "add", "-A") + gitCommit(t, shut, "commit", "-q", "-m", "a peer capture") + locked := filepath.Join(shut, ".abcd", "work", "issues", "open") + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + gone := filepath.Join(outside, "wt-gone") + gitCmd(t, repo, "worktree", "add", "-q", "-b", "feat/gone", gone, "main") + if err := os.RemoveAll(gone); err != nil { + t.Fatal(err) + } + + absForms := []string{outside} + if r, err := filepath.EvalSymlinks(outside); err == nil && r != outside { + absForms = append(absForms, r) + } + noOutsidePath := func(what, s string) { + t.Helper() + noHomePath(t, home, s) + for _, abs := range absForms { + if strings.Contains(s, abs) { + t.Errorf("%s prints the absolute worktree path under %s:\n%s", what, abs, s) + } + } + } + + raw := runCLI(t, "peers", "--json") + noOutsidePath("peers --json", string(raw)) + var got struct { + Peers []struct { + Branch string `json:"branch"` + Path string `json:"path"` + NotRead string `json:"not_read"` + } `json:"peers"` + Skipped []struct { + Branch string `json:"branch"` + Path string `json:"path"` + } `json:"skipped"` + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("--json is not JSON: %v\n%s", err, raw) + } + want := map[string]string{"feat/live": "wt-live", "feat/shut": "wt-shut", "feat/gone": "wt-gone"} + seen := map[string]string{} + notRead := "" + for _, p := range got.Peers { + if p.Path != "" { + seen[p.Branch] = p.Path + } + if p.Branch == "feat/shut" { + notRead = p.NotRead + } + } + if !strings.Contains(notRead, "wt-shut/") { + t.Errorf("the unreadable peer's reason = %q, want one naming the folder under wt-shut", notRead) + } + for _, s := range got.Skipped { + seen[s.Branch] = s.Path + } + for branch, path := range want { + if seen[branch] != path { + t.Errorf("%s is shown at %q, want its directory name %q\n%s", branch, seen[branch], path, raw) + } + } + + noOutsidePath("peers", string(runCLI(t, "peers"))) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"iss-100"}, &stdout, &stderr); code == 0 { + t.Fatalf("the dispatcher succeeded for a record this checkout lacks:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "feat/live at wt-live holds it") { + t.Errorf("the peer-held refusal does not name the worktree by its directory name:\n%s", stderr.String()) + } + noOutsidePath("the peer-held refusal", stderr.String()) +} diff --git a/internal/surface/cli/source.go b/internal/surface/cli/source.go index b22624204..8fafe67ae 100644 --- a/internal/surface/cli/source.go +++ b/internal/surface/cli/source.go @@ -49,7 +49,10 @@ func sourceCorpusDir(flag string) (string, error) { return dir, nil } -// sourceError maps a core error to the verb's exit code and one line. +// sourceError maps a core error to the verb's exit code and one line. The +// corpus directory keeps RedactHome, not fsutil.DisplayPath: it is the store the +// person names with --corpus or creates with `source init`, and a base name +// would not say where to create or find it. 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`)", From 326ed4a694742231f1aa2d93e8df00e22b12b35a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:39:51 +0100 Subject: [PATCH 46/78] =?UTF-8?q?chore:=20resolve=20iss-2609281329007423?= =?UTF-8?q?=20=E2=80=94=20a=20directory=20outside=20HOME=20is=20shown=20by?= =?UTF-8?q?=20its=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609281329007423 Assisted-by: Claude:claude-opus-5-5 --- ...kout-worktree-or-store-path-outside-home-is-printed.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md (60%) diff --git a/.abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md b/.abcd/work/issues/resolved/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md similarity index 60% rename from .abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md rename to .abcd/work/issues/resolved/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md index e5b624cb2..90df6a19c 100644 --- a/.abcd/work/issues/open/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md +++ b/.abcd/work/issues/resolved/iss-2609281329007423-a-checkout-worktree-or-store-path-outside-home-is-printed.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainCap" origin: researcher-authored production_mode: hand-written found_at: "internal/fsutil/paths.go" +resolution: "fsutil.DisplayPath states the display rule once (home-relative under HOME, the directory's base name outside it) and DisplayPathsIn applies it inside a message; scrubPaths, ledgerIdentityOf and every confirmed checkout or worktree site route through them, and paths the reader acts on (an await's brief and receipt, the sources corpus, the history store notes) keep RedactHome" +impact: fix +resolved_by: + commit: "1d334d124" --- A checkout, worktree or store path outside HOME is printed whole by the surfaces that redact through fsutil.RedactHome alone: RedactHome turns a path under HOME into ~/rel and leaves every other absolute path untouched, so a sibling worktree in /private/tmp or on another volume reaches abcd peers (--json path, not_read, skipped, the peer-held refusal), the implement check's contention detail, history ingest's destination and orphan cwd, and implement status's lane worktree as a full local path in output a person pastes elsewhere. The display rule that fixes it (under HOME the home-relative form, outside HOME the directory's base name) is stated inline twice, in scrubPaths and in the capture verbs' ledgerIdentityOf, and in no primitive a new surface can reach, which is why each new surface re-derives RedactHome alone. + +## Grounds + +- pursued: a checkout or worktree outside HOME is printed by its directory name by peers, the implement peers check, history ingest and implement status; TestPeersNamesAWorktreeOutsideHomeByItsDirectoryName or its siblings failing, or TestNoInlineBaseNameDisplayRule finding an inline copy, would show it wrong From cf65d2c188fe19b43e2a7431f8f1834d47a03cf5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:48:11 +0100 Subject: [PATCH 47/78] fix(intent): a settled label opens a sub-bullet and follows a bold's colon The drainInt review found two latent shapes the narrowed label rule read as questions although the recorded rule settles them: a nested sub-bullet continuation (` - Resolved: ...`), which opens a line of the item, and a colon after the closing bold (`**Which id?**: Resolved: ...`). The label pattern admits both. The open-question count across every committed intent is unchanged (390 questions, byte-identical listing before and after). Refs: iss-2609260932374727 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/34-build.md | 5 +++-- internal/core/intent/questions.go | 17 ++++++++++------- internal/core/intent/questions_test.go | 9 +++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index c64d5c1b8..952bdea3f 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -41,8 +41,9 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 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:`) opening a line of the item, after a closing bold or after a - dash — is not a question; the same word and colon mid-sentence are prose. + `Deferred:`) opening a line of the item (a nested sub-bullet included), + after a closing bold (with or without a colon after it) or after a dash — is + not a question; the same word and colon mid-sentence are prose. 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`, and its diff --git a/internal/core/intent/questions.go b/internal/core/intent/questions.go index fd0826f95..20c8b710f 100644 --- a/internal/core/intent/questions.go +++ b/internal/core/intent/questions.go @@ -30,12 +30,14 @@ var ( settledBoldRe = regexp.MustCompile(`(?i)\*\*(resolved|deferred|explicitly deferred|explicit deferral)\b`) // settledLabelRe is the disposition as a LABEL (`resolved:`, `RESOLVED:`, // `Deferred:`), and a label only where a label is written: opening a line of - // the item, after a closing bold (`**Which surface?** RESOLVED:`), or after - // a dash (`**Refusal breadth** — resolved:`). Mid-sentence the same word - // and colon are prose — "once the split is resolved: the old or the new?" - // is a question — and reading them as a marker let build start past it + // the item — a nested sub-bullet continuation (` - Resolved: …`) opens one + // too — after a closing bold, with or without a colon after it + // (`**Which surface?** RESOLVED:`, `**Which id?**: Resolved:`), or after a + // dash (`**Refusal breadth** — resolved:`). Mid-sentence the same word and + // colon are prose — "once the split is resolved: the old or the new?" is a + // question — and reading them as a marker let build start past it // (iss-2609260932374727). - settledLabelRe = regexp.MustCompile(`(?i)(^|\*\*[ \t]*|[—–][ \t]*|[ \t]-[ \t]+)(resolved|deferred)[ \t]*:`) + settledLabelRe = regexp.MustCompile(`(?i)(^|^[-*][ \t]+|\*\*:?[ \t]*|[—–][ \t]*|[ \t]-[ \t]+)(resolved|deferred)[ \t]*:`) ) // OpenQuestions returns the questions an intent's `## Open Questions` section @@ -55,8 +57,9 @@ var ( // with the word (`**Resolved — …**`, `**Deferred**`, `**explicitly // deferred**`, `**explicit deferral**`) anywhere in the item, continuation // lines included, or the word as a label (`resolved:`, `Deferred:`) -// opening a line of the item, after a closing bold, or after a dash — is -// not a question. The same word and colon mid-sentence are prose. +// opening a line of the item (a nested sub-bullet included), after a +// closing bold (and an optional colon), or after a dash — is not a +// question. The same word and colon mid-sentence are prose. // // 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, diff --git a/internal/core/intent/questions_test.go b/internal/core/intent/questions_test.go index cd978de57..368534fc0 100644 --- a/internal/core/intent/questions_test.go +++ b/internal/core/intent/questions_test.go @@ -91,6 +91,15 @@ func TestOpenQuestionsReadsTheSettledConvention(t *testing.T) { "- Which runner?\n Deferred: to the runner intent.\n", nil}, {"a label after a closing bold and a parenthetical dash (itd-93)", head + "- **Relationship to itd-73** (derived versioning) — RESOLVED: the CHANGELOG.\n", nil}, + {"a label opening a nested sub-bullet continuation", head + + "- Which id wins?\n - Resolved: the new one.\n" + + "- Which runner?\n * Deferred: to the runner intent.\n", nil}, + {"a label after a colon that follows the closing bold", head + + "- **Which id?**: Resolved: the new one.\n" + + "- **Which runner?**:Deferred: to the runner intent.\n", nil}, + {"a sub-bullet that only mentions resolution mid-sentence is still a question", head + + "- Which id wins?\n - once the split is resolved: the old or the new?\n", + []string{"Which id wins?"}}, {"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 + From 63bdc54ddea34066faac74ddad3e00edbb423f33 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:48:51 +0100 Subject: [PATCH 48/78] refactor(cli): the sentence rewrite reads the block through frontmatter.Close withDescription kept a private copy of Close's walk, one difference short of identical: it read on past an unclosed opening delimiter and rewrote a description line the reader treats as body prose. It now asks Close for the block's extent, and a page whose frontmatter is never closed is refused. Refs: iss-2608221126066379 Assisted-by: Claude:claude-opus-5-5 --- internal/surface/cli/sentences.go | 11 +++++------ internal/surface/cli/sentences_test.go | 6 ++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/surface/cli/sentences.go b/internal/surface/cli/sentences.go index e24cb5e69..1754e0961 100644 --- a/internal/surface/cli/sentences.go +++ b/internal/surface/cli/sentences.go @@ -107,16 +107,15 @@ func SentencePages(repoRoot string) ([]SentencePage, error) { // withDescription returns page with its frontmatter `description:` line // replaced by the sentence, double-quoted because every sentence carries a // colon followed by a space, which a plain YAML scalar may not. Nothing else in -// the page changes. +// the page changes. The block's extent is frontmatter.Close's, so a leading +// `---` nothing closes is no frontmatter here exactly as it is to the reader. func withDescription(page, sentence string) (string, error) { lines := strings.Split(page, "\n") - if len(lines) == 0 || !frontmatter.IsDelimiter(frontmatter.TrimBOM(lines[0])) { + closing := frontmatter.Close(lines) + if closing < 0 { return "", errors.New("the page has no frontmatter to carry the sentence") } - for i := 1; i < len(lines); i++ { - if frontmatter.IsDelimiter(lines[i]) { - break - } + for i := 1; i < closing; i++ { if strings.HasPrefix(lines[i], "description:") { lines[i] = "description: " + frontmatter.QuoteScalar(sentence) return strings.Join(lines, "\n"), nil diff --git a/internal/surface/cli/sentences_test.go b/internal/surface/cli/sentences_test.go index 1e01d2ded..c42d18e42 100644 --- a/internal/surface/cli/sentences_test.go +++ b/internal/surface/cli/sentences_test.go @@ -392,4 +392,10 @@ func TestWithDescriptionRewritesOnlyTheDescription(t *testing.T) { if _, err := withDescription("# x\n", "S: Writes nothing; refuses y."); err == nil { t.Fatal("a page with no frontmatter was rewritten instead of refused") } + // The block is read on frontmatter.Close's terms, the reader's: a leading + // `---` that nothing closes is no frontmatter, so the description line under + // it is body prose and is not rewritten. + if _, err := withDescription("---\ndescription: Old words.\n\n# x\n", "S: Writes nothing; refuses y."); err == nil { + t.Fatal("a page whose frontmatter is never closed was rewritten instead of refused") + } } From 2b6e0bf8e1b30cad132ae026dfb3956429f1e2a3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:03:30 +0100 Subject: [PATCH 49/78] refactor(frontmatter): the private delimiter walks route through Close and CloseAfter Five delimiter-compare variants stood beside the canonical IsDelimiter. The lint gates (frontmatterOpen, frontmatterBodyStart, recordBodyStart, agentCapabilityScope), the glossary opener, the launch prose gate and the disposition reader compared with TrimSpace, so an indented rule opened or closed a block the reader reads as body; the site stripper opened on any line beginning with three dashes; memory kept its own close predicate; and capture's parser, its writer and the principles reader each kept a copy of Close's walk. frontmatter.CloseAfter is Close's walk for a reader that located the opener itself (lint and the glossary admit an attribution comment above it), and every one of those readers now asks IsDelimiter, Close or CloseAfter. The deliberate differences stay and say why: memory's opener tolerates an indented delimiter, the transcript store reads its own format byte-exact, and the reading exclusion floor reads more broadly than any reader so it refuses more, never less. TestNoPrivateDelimiterCompare scans every non-test string literal for a `---` delimiter and fails on one outside the pinned, reasoned allowlist. No committed markdown file reads differently: seven readers probed over all 2747 committed .md files return the same answer before and after. Refs: iss-2608270908348042 Assisted-by: Claude:claude-opus-5-5 --- internal/core/capture/parse.go | 15 +- internal/core/capture/serialize.go | 13 +- internal/core/frontmatter/close_test.go | 35 ++++ .../frontmatter/delimiter_canonical_test.go | 154 ++++++++++++++++++ internal/core/frontmatter/frontmatter.go | 18 +- internal/core/glossary/delimiter_rule_test.go | 20 +++ internal/core/glossary/index.go | 5 +- internal/core/history/store.go | 5 + .../core/issueschema/delimiter_rule_test.go | 19 +++ internal/core/issueschema/disposition.go | 17 +- internal/core/launch/delimiter_rule_test.go | 20 +++ internal/core/launch/gates.go | 17 +- internal/core/lint/agentcontract.go | 13 +- internal/core/lint/delimiter_rule_test.go | 38 +++++ internal/core/lint/lint.go | 20 +-- internal/core/lint/principles.go | 10 +- internal/core/lint/schema.go | 13 +- internal/core/memory/writer.go | 3 +- internal/core/memory/yaml.go | 27 ++- internal/core/site/markdown_test.go | 15 ++ internal/core/site/sections.go | 17 +- 21 files changed, 398 insertions(+), 96 deletions(-) create mode 100644 internal/core/frontmatter/delimiter_canonical_test.go create mode 100644 internal/core/glossary/delimiter_rule_test.go create mode 100644 internal/core/issueschema/delimiter_rule_test.go create mode 100644 internal/core/launch/delimiter_rule_test.go create mode 100644 internal/core/lint/delimiter_rule_test.go diff --git a/internal/core/capture/parse.go b/internal/core/capture/parse.go index f10a65daa..4ff3cba5f 100644 --- a/internal/core/capture/parse.go +++ b/internal/core/capture/parse.go @@ -35,17 +35,10 @@ func parseFrontmatterAndBody(text string) (map[string]any, string, error) { if len(lines) == 0 || !frontmatter.IsDelimiter(frontmatter.TrimBOM(lines[0])) { return nil, "", fmt.Errorf("%w: frontmatter must start with '---' on the first line", ErrMalformedFrontmatter) } - closeIdx := -1 - for i := 1; i < len(lines); i++ { - ln := lines[i] - if strings.HasPrefix(ln, " ") || strings.HasPrefix(ln, "\t") { - continue - } - if frontmatter.IsDelimiter(ln) { - closeIdx = i - break - } - } + // The close is frontmatter.CloseAfter's, the one closing walk: an indented + // line is never a close there, which is the refusal this parser needs + // (iss-2608270908348042). + closeIdx := frontmatter.CloseAfter(lines, 0) if closeIdx == -1 { return nil, "", fmt.Errorf("%w: frontmatter not terminated: missing closing '---'", ErrMalformedFrontmatter) } diff --git a/internal/core/capture/serialize.go b/internal/core/capture/serialize.go index 098faae37..26bbdfea0 100644 --- a/internal/core/capture/serialize.go +++ b/internal/core/capture/serialize.go @@ -343,16 +343,9 @@ func frontmatterBounds(lines []string) (openIdx, closeIdx int, err error) { if openIdx == -1 { return -1, -1, fmt.Errorf("%w: content has no frontmatter block", ErrMalformedFrontmatter) } - for j := openIdx + 1; j < len(lines); j++ { - ln := lines[j] - if strings.HasPrefix(ln, " ") || strings.HasPrefix(ln, "\t") { - continue - } - if frontmatter.IsDelimiter(ln) { - closeIdx = j - break - } - } + // The close is frontmatter.CloseAfter's, the one closing walk + // (iss-2608270908348042). + closeIdx = frontmatter.CloseAfter(lines, openIdx) if closeIdx == -1 { return -1, -1, fmt.Errorf("%w: frontmatter not terminated", ErrMalformedFrontmatter) } diff --git a/internal/core/frontmatter/close_test.go b/internal/core/frontmatter/close_test.go index 21ce93e43..11d839c70 100644 --- a/internal/core/frontmatter/close_test.go +++ b/internal/core/frontmatter/close_test.go @@ -41,3 +41,38 @@ func TestCloseFindsTheBlockFieldsReads(t *testing.T) { }) } } + +// TestCloseAfterJudgesTheCloseAsCloseDoes: a reader that located the opening +// delimiter itself (past an attribution comment) asks CloseAfter for the close, +// and gets Close's answer about which lines close a block — IsDelimiter's, so an +// indented rule and a mid-file ZWNBSP rule are body lines +// (iss-2608270908348042). +func TestCloseAfterJudgesTheCloseAsCloseDoes(t *testing.T) { + t.Parallel() + cases := []struct { + name string + doc string + open int + want int + }{ + {"opening past a comment", "\n---\nid: a\n---\nbody\n", 1, 3}, + {"an indented rule is not a close", "\n---\nid: a\n ---\n---\n", 1, 4}, + {"a mid-file ZWNBSP rule is not a close", "\n---\nid: a\n\ufeff---\n---\n", 1, 4}, + {"trailing whitespace and CRLF", "\r\n---\r\nid: a\r\n--- \r\n", 1, 3}, + {"unclosed", "\n---\nid: a\n", 1, -1}, + {"no opening", "body\n", -1, -1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + lines := strings.Split(tc.doc, "\n") + if got := CloseAfter(lines, tc.open); got != tc.want { + t.Fatalf("CloseAfter = %d, want %d", got, tc.want) + } + // With line ends kept, the answer is the same. + if got := CloseAfter(strings.SplitAfter(tc.doc, "\n"), tc.open); got != tc.want { + t.Fatalf("CloseAfter (ends kept) = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/internal/core/frontmatter/delimiter_canonical_test.go b/internal/core/frontmatter/delimiter_canonical_test.go new file mode 100644 index 000000000..e8dbdbc62 --- /dev/null +++ b/internal/core/frontmatter/delimiter_canonical_test.go @@ -0,0 +1,154 @@ +package frontmatter + +import ( + "fmt" + "go/scanner" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// delimiterSite is one allowlisted file: how many string literals spelling a +// `---` delimiter it holds, and why none of them is a second delimiter rule. +type delimiterSite struct { + count int + reason string +} + +// delimiterSites names every non-test Go file outside this package whose string +// literals spell a `---` delimiter at a line start, with the number of such +// literals and the reason each is not a private compare. The count is pinned so +// a compare added to a file already on the list is a new claim too. The default +// for a file this test names is to route it through IsDelimiter, Close or +// CloseAfter. +var delimiterSites = map[string]delimiterSite{ + // Writers: they emit a delimiter and judge none. + "internal/core/capture/serialize.go": {2, "a WRITER: buildIssueText emits the block's two delimiters; the reader side is frontmatterBounds, which asks IsDelimiter and CloseAfter"}, + "internal/core/decide/decide.go": {2, "a WRITER: the ADR skeleton's two delimiters"}, + "internal/core/intent/create.go": {2, "a WRITER: the minted intent's two delimiters"}, + "internal/core/intent/consistency.go": {2, "a WRITER: the review record's two delimiters"}, + "internal/core/spec/spec.go": {2, "a WRITER: the minted spec's two delimiters"}, + "internal/core/report/report.go": {4, "a WRITER: two report templates' delimiters; the report reader judges by IsDelimiter"}, + "internal/core/source/add.go": {2, "a WRITER: a source entry's two delimiters"}, + "internal/core/lab/record.go": {2, "a WRITER: a probe record's two delimiters"}, + "internal/core/lab/mint.go": {1, "a WRITER: the lab entry's block, both delimiters in one format string"}, + "internal/core/memory/schema.go": {2, "a WRITER: rebuilds a region as a block to hand parseFrontmatter; it judges no delimiter"}, + "internal/gittest/repo.go": {2, "a WRITER: a test-fixture record's block's two delimiters"}, + "internal/surface/cli/history.go": {1, "a WRITER: a separator line between rendered transcripts; not frontmatter"}, + "internal/core/positioning/render.go": {1, "a WRITER: a unified diff's `--- a/` header; not frontmatter"}, + "internal/core/lint/subverbs.go": {1, "a markdown TABLE's separator row (`|---|`), not a frontmatter delimiter"}, + // Deliberate, documented differences. + "internal/core/memory/yaml.go": {3, "the memory store's opener tolerates an indented delimiter (documented at frontmatterOpenIndex and textOpensFrontmatter); joinFileFrontmatter WRITES the block's two delimiters; every close is IsDelimiter"}, + "internal/core/memory/writer.go": {3, "a WRITER rebuilding a region for parseFrontmatter, and a byte-0 test that leaves a page with a tolerated preamble alone because rebuilding it would drop the preamble"}, + "internal/core/history/store.go": {6, "the transcript store's own record format, written by marshalRecord and read back byte-exact: a record this store did not write is refused, which is the point"}, + "internal/core/reading/project.go": {3, "the reading exclusion floor: it reads a block more broadly than IsDelimiter (any line OPENING with three dashes, and YAML's `...`) so an excluded key is refused in every block a YAML-aware reader could see; the floor refuses more, never less"}, +} + +// TestNoPrivateDelimiterCompare is the one-canonical-primitive detector for the +// frontmatter delimiter rule, the counterpart of mdrecord's fence detector. +// +// IsDelimiter is the one delimiter rule and Close/CloseAfter the one closing +// walk. Private compares disagreed with them — a TrimSpace compare in the gates +// closed a block on an indented rule the reader reads as body, a bare prefix test +// in the site opened one on `----` — and a gate that disagrees with its reader +// about where the block ends passes the record the reader refuses +// (iss-2608270908348042). +// +// The check reads string LITERALS through go/scanner, so a comment quoting a +// delimiter is not a claim; a literal counts when its value opens with `---` or +// carries one at the start of a later line. A delimiter assembled at run time is +// outside its reach, and is left to review. +func TestNoPrivateDelimiterCompare(t *testing.T) { + root := filepath.Join("..", "..", "..") // internal/core/frontmatter -> repository root + var offenders []string + seen := map[string]bool{} + for _, dir := range []string{"internal", "cmd"} { + err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path == filepath.Join(root, "internal", "core", "frontmatter") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + n := delimiterLiterals(data) + if n == 0 { + return nil + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + seen[rel] = true + s, ok := delimiterSites[rel] + switch { + case !ok: + offenders = append(offenders, fmt.Sprintf("%s (spells %d `---` delimiter literal(s) and is not allowlisted)", rel, n)) + case s.count != n: + offenders = append(offenders, fmt.Sprintf("%s (spells %d `---` delimiter literal(s); the allowlist names %d)", rel, n, s.count)) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } + } + if len(offenders) > 0 { + sort.Strings(offenders) + t.Errorf("`---` delimiter literals outside frontmatter (route through IsDelimiter, Close or CloseAfter, or allowlist with a reason and the count):\n %s", + strings.Join(offenders, "\n ")) + } + for rel := range delimiterSites { + if !seen[rel] { + t.Errorf("%s is allowlisted but no longer spells a `---` delimiter literal; remove the entry", rel) + } + } +} + +// delimiterLiterals counts the Go string literals in src whose value opens with +// `---` or carries `---` at the start of a later line. +func delimiterLiterals(src []byte) int { + fset := token.NewFileSet() + file := fset.AddFile("", fset.Base(), len(src)) + var s scanner.Scanner + s.Init(file, src, nil, 0) + n := 0 + for { + _, tok, lit := s.Scan() + if tok == token.EOF { + return n + } + if tok != token.STRING { + continue + } + v, err := strconv.Unquote(lit) + if err != nil { + continue + } + if strings.HasPrefix(v, "---") || strings.Contains(v, "\n---") { + n++ + } + } +} + +// TestDelimiterLiteralsReadsLiteralsNotComments pins the counter the detector +// stands on: a compare in code counts, a delimiter quoted in a comment does not, +// and a raw string spelling a block counts once. +func TestDelimiterLiteralsReadsLiteralsNotComments(t *testing.T) { + t.Parallel() + src := "package p\n// a comment quoting \"---\" is not a claim\nvar a = x == \"---\"\nvar b = `id: a\n---\n`\nvar c = \"-- \"\n" + if got := delimiterLiterals([]byte(src)); got != 2 { + t.Fatalf("delimiterLiterals = %d, want 2", got) + } +} diff --git a/internal/core/frontmatter/frontmatter.go b/internal/core/frontmatter/frontmatter.go index cad6f9b3e..1a0ca5074 100644 --- a/internal/core/frontmatter/frontmatter.go +++ b/internal/core/frontmatter/frontmatter.go @@ -129,7 +129,23 @@ func Close(lines []string) int { if len(lines) == 0 || !IsDelimiter(TrimBOM(lines[0])) { return -1 } - for i := 1; i < len(lines); i++ { + return CloseAfter(lines, 0) +} + +// CloseAfter returns the index of the first delimiter after the opening one at +// lines[open], or -1 when nothing closes the block. It is Close's walk for a +// reader that has located the opening delimiter itself — record-lint and the +// glossary admit an attribution comment above it, so their block need not open +// at line 0 — and it judges every closing line by IsDelimiter exactly as Close +// does: an indented ` ---` and a mid-file "\ufeff---" are body lines, never a +// close. Whether lines[open] opens a block is the caller's question. +// +// The lines may carry their end-of-line bytes or not; IsDelimiter trims both. +func CloseAfter(lines []string, open int) int { + if open < 0 { + return -1 + } + for i := open + 1; i < len(lines); i++ { if IsDelimiter(lines[i]) { return i } diff --git a/internal/core/glossary/delimiter_rule_test.go b/internal/core/glossary/delimiter_rule_test.go new file mode 100644 index 000000000..5e210eb82 --- /dev/null +++ b/internal/core/glossary/delimiter_rule_test.go @@ -0,0 +1,20 @@ +package glossary + +import ( + "strings" + "testing" +) + +// TestGlossaryOpensTheBlockOnTheOneDelimiterRule: the glossary judges the +// opening delimiter by frontmatter.IsDelimiter, so an indented rule opens no +// block here as it opens none to Fields; the comment preamble stays tolerated +// (iss-2608270908348042). +func TestGlossaryOpensTheBlockOnTheOneDelimiterRule(t *testing.T) { + t.Parallel() + if got := frontmatterOpen(strings.Split(" ---\nterm: a\n---\n", "\n")); got != -1 { + t.Errorf("frontmatterOpen = %d, want -1 (an indented rule opens nothing)", got) + } + if got := frontmatterOpen(strings.Split("\n---\nterm: a\n---\n", "\n")); got != 1 { + t.Errorf("frontmatterOpen past a comment = %d, want 1", got) + } +} diff --git a/internal/core/glossary/index.go b/internal/core/glossary/index.go index 6990accb7..129c5b238 100644 --- a/internal/core/glossary/index.go +++ b/internal/core/glossary/index.go @@ -237,7 +237,10 @@ func frontmatterOpen(lines []string) int { if i >= len(lines) { return -1 } - if strings.TrimSpace(lines[i][col:]) == "---" && strings.TrimSpace(frontmatter.TrimBOM(lines[i][:col])) == "" { + // The comment preamble is tolerated; the delimiter line is judged by + // frontmatter.IsDelimiter, the one rule, so an indented ` ---` opens + // nothing here as it opens nothing to Fields (iss-2608270908348042). + if frontmatter.TrimBOM(lines[i][:col]) == "" && frontmatter.IsDelimiter(lines[i][col:]) { return i } return -1 diff --git a/internal/core/history/store.go b/internal/core/history/store.go index fdb6b2116..bfac1bb1a 100644 --- a/internal/core/history/store.go +++ b/internal/core/history/store.go @@ -262,6 +262,11 @@ func marshalBody(body string) string { // parseRecord splits a record file into its metadata and redacted body. The // Path field is set by the caller. Returns an error when the frontmatter fence // is missing or a required field is malformed. +// +// The delimiters are matched byte-exact, deliberately NOT by +// frontmatter.IsDelimiter: this is the store's own format, written only by this +// file, so a record whose fence is not the one the writer emits was not written +// here and is refused rather than read leniently (iss-2608270908348042). func parseRecord(data []byte) (Record, string, error) { text := string(data) if !strings.HasPrefix(text, "---\n") { diff --git a/internal/core/issueschema/delimiter_rule_test.go b/internal/core/issueschema/delimiter_rule_test.go new file mode 100644 index 000000000..ed0fd0412 --- /dev/null +++ b/internal/core/issueschema/delimiter_rule_test.go @@ -0,0 +1,19 @@ +package issueschema + +import "testing" + +// TestParseDispositionOpensOnTheOneDelimiterRule: a disposition's block is read +// on frontmatter.Close's terms, the strict ledger parser's, so an indented +// opening rule is no block at all — the record is not well-formed, and its +// fields are not trusted (iss-2608270908348042). +func TestParseDispositionOpensOnTheOneDelimiterRule(t *testing.T) { + t.Parallel() + rec := ParseDisposition("dsp-1", " ---\nstate: held\n---\n") + if rec.WellFormed || rec.State != "" { + t.Fatalf("ParseDisposition read an indented opener as a block: %+v", rec) + } + rec = ParseDisposition("dsp-1", "---\nstate: held\n---\n") + if rec.State != "held" { + t.Fatalf("ParseDisposition = %+v, want state held", rec) + } +} diff --git a/internal/core/issueschema/disposition.go b/internal/core/issueschema/disposition.go index d27101230..e6ee79fad 100644 --- a/internal/core/issueschema/disposition.go +++ b/internal/core/issueschema/disposition.go @@ -75,19 +75,10 @@ func ParseDisposition(id, content string) DispositionRecord { // A BOM is not preamble: it is the file's encoding mark, which the strict // ledger parser and frontmatter.Fields both trim at line 0 and only there // (iss-2608221126066379). - if len(lines) == 0 || strings.TrimSpace(frontmatter.TrimBOM(lines[0])) != "---" { - return rec - } - closeAt := -1 - for i := 1; i < len(lines); i++ { - if strings.HasPrefix(lines[i], " ") || strings.HasPrefix(lines[i], "\t") { - continue - } - if strings.TrimSpace(lines[i]) == "---" { - closeAt = i - break - } - } + // The block's extent is frontmatter.Close's, the walk the strict ledger + // parser takes, so an indented opening rule opens nothing here either + // (iss-2608270908348042). + closeAt := frontmatter.Close(lines) if closeAt == -1 { return rec } diff --git a/internal/core/launch/delimiter_rule_test.go b/internal/core/launch/delimiter_rule_test.go new file mode 100644 index 000000000..f429f1cc3 --- /dev/null +++ b/internal/core/launch/delimiter_rule_test.go @@ -0,0 +1,20 @@ +package launch + +import "testing" + +// TestProseLinesReadsTheBlockOnTheOneDelimiterRule: the prose gate takes the +// frontmatter block's extent from frontmatter.Close, so an indented rule does +// not close it and the line after it is metadata, not prose — as Fields reads +// it (iss-2608270908348042). +func TestProseLinesReadsTheBlockOnTheOneDelimiterRule(t *testing.T) { + t.Parallel() + got := proseLines([]byte("---\ntitle: x\n ---\nkind: y\n---\nbody line\n")) + for _, l := range got { + if l.text == "kind: y" || l.text == " ---" { + t.Fatalf("proseLines read frontmatter line %d (%q) as prose: the indented rule closed the block", l.n, l.text) + } + } + if len(got) == 0 || got[0].text != "body line" { + t.Fatalf("proseLines = %+v, want the body to open at \"body line\"", got) + } +} diff --git a/internal/core/launch/gates.go b/internal/core/launch/gates.go index a3ae4867a..ad2a6ab47 100644 --- a/internal/core/launch/gates.go +++ b/internal/core/launch/gates.go @@ -258,17 +258,12 @@ func proseLines(data []byte) []proseLine { lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") var out []proseLine frontEnd := -1 // index of the closing "---"; -1 when there is no frontmatter - // A BOM ahead of the opening rule is the file's encoding mark, trimmed at - // line 0 as every frontmatter reader trims it (iss-2608221126066379). - if len(lines) > 0 && strings.TrimSpace(frontmatter.TrimBOM(lines[0])) == "---" { - for i := 1; i < len(lines); i++ { - if strings.TrimSpace(lines[i]) == "---" { - if isFrontmatter(lines[1:i]) { - frontEnd = i - } - break - } - } + // The block's extent is frontmatter.Close's: a BOM ahead of the opening + // rule trimmed at line 0 (iss-2608221126066379), and every delimiter judged + // by the one rule, so an indented rule neither opens nor closes it + // (iss-2608270908348042). + if end := frontmatter.Close(lines); end > 0 && isFrontmatter(lines[1:end]) { + frontEnd = end } // Fenced code is read through the tree's one fence rule (mdrecord), so a // longer run, a mismatched closer or an unclosed fence reads here exactly diff --git a/internal/core/lint/agentcontract.go b/internal/core/lint/agentcontract.go index 79d01cd06..5e19052f0 100644 --- a/internal/core/lint/agentcontract.go +++ b/internal/core/lint/agentcontract.go @@ -31,6 +31,7 @@ import ( "regexp" "strings" + "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -445,13 +446,17 @@ func agentCapabilityScope(lines []string) map[string]string { // re-judging it here without the trim refused a BOM-led prompt's block // (iss-2608221126066379). lines = lines[start:] + // The block ends at frontmatter.CloseAfter's close, the one closing walk + // (iss-2608270908348042); an unclosed block is read to the end of the file, + // as this reader always has. + end := frontmatter.CloseAfter(lines, 0) + if end < 0 { + end = len(lines) + } scope := map[string]string{} inScope, member := false, "" - for i := 1; i < len(lines); i++ { + for i := 1; i < end; i++ { line := strings.TrimRight(lines[i], "\r") - if strings.TrimSpace(line) == "---" { - break - } indented := line != "" && (line[0] == ' ' || line[0] == '\t') if !indented { inScope, member = strings.HasPrefix(line, "capability_scope:"), "" diff --git a/internal/core/lint/delimiter_rule_test.go b/internal/core/lint/delimiter_rule_test.go new file mode 100644 index 000000000..24aa64025 --- /dev/null +++ b/internal/core/lint/delimiter_rule_test.go @@ -0,0 +1,38 @@ +package lint + +import ( + "strings" + "testing" +) + +// TestLintReadsTheBlockOnTheOneDelimiterRule: record-lint's frontmatter readers +// judge the delimiter by frontmatter.IsDelimiter and close the block by +// CloseAfter, so an indented rule neither opens nor closes a block — exactly +// as Fields, the reader, reads it. A private TrimSpace compare closed the block +// on the indented rule, and the gate read a different block from the reader's +// (iss-2608270908348042). +func TestLintReadsTheBlockOnTheOneDelimiterRule(t *testing.T) { + t.Parallel() + indentedClose := strings.Split("---\nid: a\n ---\nb: c\n---\n# Title\n", "\n") + if got := frontmatterBodyStart(indentedClose); got != 5 { + t.Errorf("frontmatterBodyStart = %d, want 5 (the indented rule is not a close)", got) + } + if got := recordBodyStart(indentedClose); got != 5 { + t.Errorf("recordBodyStart = %d, want 5 (the indented rule is not a close)", got) + } + if title, line := recordH1(indentedClose); title != "Title" || line != 6 { + t.Errorf("recordH1 = %q at %d, want \"Title\" at 6", title, line) + } + indentedOpen := strings.Split(" ---\nid: a\n---\n# Title\n", "\n") + if got := frontmatterOpen(indentedOpen); got != -1 { + t.Errorf("frontmatterOpen = %d, want -1 (an indented rule opens nothing)", got) + } + // The comment preamble stays this reader's deliberate tolerance. + if got := frontmatterOpen(strings.Split("\n---\nid: a\n---\n", "\n")); got != 1 { + t.Errorf("frontmatterOpen past a comment = %d, want 1", got) + } + scope := agentCapabilityScope(strings.Split("---\ncapability_scope:\n designed_for: [a]\n ---\n task_classes: [b]\n---\n", "\n")) + if scope["task_classes"] != "[b]" { + t.Errorf("agentCapabilityScope = %v, want task_classes read past the indented rule", scope) + } +} diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 479ad96aa..37cea1d5e 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -2722,6 +2722,11 @@ func parseYAMLStringList(v string) []string { return frontmatter.StringList(v) } // untrimmed BOM ahead of the `---` (or ahead of a leading comment) would make a // well-formed record read as having no frontmatter and slip every // frontmatter-keyed blocker. +// +// The comment preamble is this reader's deliberate tolerance; the delimiter +// line itself is judged by frontmatter.IsDelimiter, the one rule, so an +// indented ` ---` opens nothing here exactly as it opens nothing to Fields +// (iss-2608270908348042). func frontmatterOpen(lines []string) int { // The comments are mdrecord's to locate (iss-2609251518418878); a line // holding prose after a comment's closer is content, not a comment. @@ -2729,7 +2734,7 @@ func frontmatterOpen(lines []string) int { if i >= len(lines) { return -1 } - if strings.TrimSpace(lines[i][col:]) == "---" && strings.TrimSpace(frontmatter.TrimBOM(lines[i][:col])) == "" { + if frontmatter.TrimBOM(lines[i][:col]) == "" && frontmatter.IsDelimiter(lines[i][col:]) { return i } return -1 @@ -2741,16 +2746,11 @@ func frontmatterOpen(lines []string) int { // file whose frontmatter carries a `core/epic` term reference is never scanned as // prose just because a comment precedes its `---`. func frontmatterBodyStart(lines []string) int { - open := frontmatterOpen(lines) - if open < 0 { - return 0 - } - for j := open + 1; j < len(lines); j++ { - if strings.TrimSpace(lines[j]) == "---" { - return j + 1 - } + // The close is frontmatter.CloseAfter's (iss-2608270908348042). + if end := frontmatter.CloseAfter(lines, frontmatterOpen(lines)); end >= 0 { + return end + 1 } - return 0 // unterminated frontmatter: treat all as body rather than swallow the file + return 0 // no frontmatter, or unterminated: treat all as body rather than swallow the file } // stripInlineCode blanks the contents of single-backtick inline code spans (and diff --git a/internal/core/lint/principles.go b/internal/core/lint/principles.go index d47c83d1e..eeb343f07 100644 --- a/internal/core/lint/principles.go +++ b/internal/core/lint/principles.go @@ -176,13 +176,9 @@ func FindPrincipleStatement(lines []string) (PrincipleStatement, bool) { // principleBodyStart is the first line after the leading frontmatter block, 0 // when there is none: a YAML comment is a `#` line, and it is not a title. func principleBodyStart(lines []string) int { - if len(lines) == 0 || !frontmatter.IsDelimiter(frontmatter.TrimBOM(lines[0])) { - return 0 - } - for i := 1; i < len(lines); i++ { - if !strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], "\t") && frontmatter.IsDelimiter(lines[i]) { - return i + 1 - } + // frontmatter.Close is the one walk (iss-2608270908348042). + if end := frontmatter.Close(lines); end >= 0 { + return end + 1 } return 0 } diff --git a/internal/core/lint/schema.go b/internal/core/lint/schema.go index f0c8bdbb7..08e1e8590 100644 --- a/internal/core/lint/schema.go +++ b/internal/core/lint/schema.go @@ -2155,15 +2155,16 @@ func recordBodyStart(lines []string) int { // Whether that line opens frontmatter is frontmatterOpen's question, which // trims a BOM ahead of the delimiter; a private compare here did not, and // took a BOM-led issue's `---` for its title (iss-2608221126066379). + // The close is frontmatter.CloseAfter's, the one closing walk; a private + // TrimSpace compare closed on an indented rule no other reader closes on + // (iss-2608270908348042). An unclosed block still reads as swallowing the + // document, so no frontmatter line is taken for a title. i, _ := mdrecord.FirstContent(lines) if i < len(lines) && frontmatterOpen(lines) == i { - i++ - for i < len(lines) && strings.TrimSpace(lines[i]) != "---" { - i++ - } - if i < len(lines) { - i++ + if end := frontmatter.CloseAfter(lines, i); end >= 0 { + return end + 1 } + return len(lines) } return i } diff --git a/internal/core/memory/writer.go b/internal/core/memory/writer.go index ad1b44293..6a04390e2 100644 --- a/internal/core/memory/writer.go +++ b/internal/core/memory/writer.go @@ -512,7 +512,8 @@ func backfillLegacy(mem string) ([]string, error) { } // Rebuilding the file from (region, body) cannot carry a tolerated // preamble across, so a page that has one is left alone rather than - // backfilled lossily. + // backfilled lossily. A byte-0 test is the question here — whether + // anything precedes the block — not a delimiter compare. if !strings.HasPrefix(text, "---") { continue } diff --git a/internal/core/memory/yaml.go b/internal/core/memory/yaml.go index 46fa113d4..40f7ff689 100644 --- a/internal/core/memory/yaml.go +++ b/internal/core/memory/yaml.go @@ -39,18 +39,11 @@ func yamlErrf(format string, a ...any) *yamlError { var keyRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)`) -// isFrontmatterClose reports whether line closes a frontmatter block. The two -// ends of the same block are held to one rule: the open is matched after a -// whitespace trim, so the close trims too. A trailing space or tab on the close -// ("--- ") is a common editor artefact, and comparing it byte-exact made the -// parsers report "frontmatter not terminated" and every reader fall back to its -// empty default — silently dropping the page's source: provenance and the lint -// gates that read it. This is the rule the canonical primitive already applies -// (internal/core/frontmatter.Fields). Callers normalise \r\n -> \n first, so -// only spaces and tabs remain to trim. -func isFrontmatterClose(line string) bool { - return strings.TrimRight(line, " \t") == "---" -} +// A frontmatter block closes on frontmatter.IsDelimiter, the one delimiter +// rule, not on a private predicate (iss-2608270908348042). It tolerates the +// trailing space or tab an editor leaves on the close ("--- "), which a +// byte-exact compare took for "frontmatter not terminated", and callers +// normalise \r\n -> \n first, so a CRLF close is a close too (iss-30). // normaliseNewlines folds \r\n and \r to \n, the first step of every parser here // (so a CRLF delimiter is seen as a delimiter — iss-30). @@ -78,6 +71,10 @@ func frontmatterOpenIndex(lines []string) (int, bool) { line = frontmatter.TrimBOM(line) } s := strings.TrimSpace(line) + // Deliberately more tolerant than frontmatter.IsDelimiter: the memory + // store's pages have always opened on an indented delimiter as well as a + // column-0 one, and textOpensFrontmatter documents that tolerance as the + // parsers' rule. The close is the canonical one. if s == "---" { return start, true } @@ -120,7 +117,7 @@ func parseFrontmatter(text string) (map[string]any, error) { i := start + 1 found := false for i < len(lines) { - if isFrontmatterClose(lines[i]) { + if frontmatter.IsDelimiter(lines[i]) { found = true break } @@ -688,7 +685,7 @@ func splitFileFrontmatter(text string) (string, string, error) { var fm []string i := start + 1 for i < len(lines) { - if isFrontmatterClose(lines[i]) { + if frontmatter.IsDelimiter(lines[i]) { var region strings.Builder for _, l := range fm { region.WriteString(l) @@ -722,7 +719,7 @@ func frontmatterKeyLine(text, key string) int { out := 0 for i := start + 1; i < len(lines); i++ { raw := lines[i] - if isFrontmatterClose(raw) { + if frontmatter.IsDelimiter(raw) { break } if raw != "" && raw[0] != ' ' && raw[0] != '\t' { diff --git a/internal/core/site/markdown_test.go b/internal/core/site/markdown_test.go index c1ffaf709..67afe7c6c 100644 --- a/internal/core/site/markdown_test.go +++ b/internal/core/site/markdown_test.go @@ -493,3 +493,18 @@ func TestSiteHref(t *testing.T) { t.Errorf("siteHref with no docs tree = %q, want the forge's view of the page", got) } } + +// TestStripFrontmatterJudgesDelimitersByTheOneRule: both delimiters are judged +// by frontmatter.IsDelimiter. A bare prefix test opened a block on `----` and +// closed one on a line that merely began with three dashes, so the site cut a +// different block from the one every record reader reads (iss-2608270908348042). +func TestStripFrontmatterJudgesDelimitersByTheOneRule(t *testing.T) { + t.Parallel() + if body, n := StripFrontmatter("----\n\n# Title\n\n---\n"); body != "----\n\n# Title\n\n---\n" || n != 0 { + t.Errorf("a four-dash rule opened a block: %q, %d", body, n) + } + body, n := StripFrontmatter("---\nid: a\n---- not a close\n--- \n# Title\n") + if body != "\n# Title\n" || n != 3 { + t.Errorf("StripFrontmatter = %q, %d; want the block closed at the trailing-space delimiter", body, n) + } +} diff --git a/internal/core/site/sections.go b/internal/core/site/sections.go index 45a8ab4ca..463cad5ef 100644 --- a/internal/core/site/sections.go +++ b/internal/core/site/sections.go @@ -77,18 +77,23 @@ func StripFrontmatter(t string) (string, int) { rest = frontmatter.TrimBOM(rest) lead = len(t) - len(rest) } - if !strings.HasPrefix(rest, "---") { + // Both delimiters are judged by frontmatter.IsDelimiter, the one rule: a + // bare prefix test opened a block on `----` or `--- yaml` and closed one on + // any line that merely began with three dashes (iss-2608270908348042). The + // cut lands after the closing delimiter's dashes, on its own line ending. + lines := strings.SplitAfter(rest, "\n") + if !frontmatter.IsDelimiter(lines[0]) { return t, 0 } - end := strings.Index(rest[3:], "\n---") + end := frontmatter.CloseAfter(lines, 0) if end < 0 { return t, 0 } - end += 3 - cut := end + 4 - if cut > len(rest) { - return t, 0 + cut := 0 + for _, ln := range lines[:end] { + cut += len(ln) } + cut += len(strings.TrimRight(lines[end], "\r\n")) return rest[cut:], strings.Count(t[:lead+cut], "\n") } From 8f0cf5f4252348a448aecd43417deebae47cf3cf Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:00 +0100 Subject: [PATCH 50/78] test(lint): pin the ledger gate's store-scoped refusal of a block sequence A list written as an indented block sequence is legitimate in the intent and ADR stores and refused by the issue ledger's reader. The issue store's reader-parity leg (35600e968) asks capture's reader itself, so the gate names that refusal for an issue record and for no other store; nothing pinned the block-sequence shape. TestRecordSchemaRefusesAnIssueBlockSequenceAsTheReaderDoes asserts the reader's refusal is the finding, with the referenced record present so no other leg speaks for it (red with the leg switched off on a copy). A parity case pins that a block closed only by a mid-file ZWNBSP rule is refused by both the reader and the gate. Refs: iss-2608270655499478, iss-2608270908348042 Assisted-by: Claude:claude-opus-5-5 --- internal/core/lint/readerparity_test.go | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/core/lint/readerparity_test.go b/internal/core/lint/readerparity_test.go index 13e18aa52..2d30156bc 100644 --- a/internal/core/lint/readerparity_test.go +++ b/internal/core/lint/readerparity_test.go @@ -32,6 +32,10 @@ func TestRecordSchemaAgreesWithTheLedgerReader(t *testing.T) { {"quoted schema_version", "schema_version: 1\n", "schema_version: \"1\"\n", true}, {"schema_version with a trailing comment", "schema_version: 1\n", "schema_version: 1 # v1\n", false}, {"found_during as a list", "found_during: t\n", "found_during: [a]\n", true}, + // A block closed only by a mid-file ZWNBSP rule is never closed to the + // reader, and the gate reads the block on the same delimiter rule + // (iss-2608270908348042). + {"closed only by a mid-file ZWNBSP rule", "found_during: t\n---\n", "found_during: t\n\ufeff---\n", true}, {"the valid record itself", good, good, false}, } for _, c := range cases { @@ -66,3 +70,33 @@ func TestRecordSchemaAgreesWithTheLedgerReader(t *testing.T) { }) } } + +// TestRecordSchemaRefusesAnIssueBlockSequenceAsTheReaderDoes is the +// block-sequence remainder of #357 (iss-2608270655499478). A list written as an +// indented block sequence is legitimate in the intent and ADR stores, whose +// readers take it, and refused by the issue ledger's reader, so the refusal is +// store-scoped: the gate asks capture's reader itself about an issue record and +// names the refusal, rather than rejecting the spelling everywhere. The +// referenced record exists, so no other leg speaks for the reader here. +func TestRecordSchemaRefusesAnIssueBlockSequenceAsTheReaderDoes(t *testing.T) { + for _, add := range []string{"related_issues:\n - iss-6\n", "blocked_by:\n - iss-6\n"} { + t.Run(strings.SplitN(add, ":", 2)[0], func(t *testing.T) { + root := t.TempDir() + seedRecRoot(t, root) + writeFile(t, root, filepath.Join("work", "issues", "open", "iss-6-b-slug.md"), validIssue("iss-6", "b-slug")) + rel := filepath.Join("work", "issues", "open", "iss-5-a-slug.md") + content := strings.Replace(validIssue("iss-5", "a-slug"), "severity: minor\n", "severity: minor\n"+add, 1) + if issueReadRefusal == nil || issueReadRefusal(content, "open", rel) == nil { + t.Fatal("fixture expectation is wrong: the ledger reader must refuse a block sequence") + } + writeFile(t, root, rel, content) + fs, err := Lint(schemaConfig(), root) + if err != nil { + t.Fatal(err) + } + if !findingWith(fs, rel, ruleRecordSchema, "ledger reader refuses") { + t.Fatalf("the gate does not name the reader's refusal of a block sequence: %+v", fs) + } + }) + } +} From ea2548d5bcf89bece3acc6cf169a81a50194b3e1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:07 +0100 Subject: [PATCH 51/78] =?UTF-8?q?chore:=20resolve=20iss-2608270908348042?= =?UTF-8?q?=20=E2=80=94=20one=20delimiter=20rule=20for=20every=20frontmatt?= =?UTF-8?q?er=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608270908348042 Assisted-by: Claude:claude-opus-5-5 --- ...imiter-compare-variants-remain-beside-the-canonical.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md (50%) diff --git a/.abcd/work/issues/open/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md b/.abcd/work/issues/resolved/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md similarity index 50% rename from .abcd/work/issues/open/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md rename to .abcd/work/issues/resolved/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md index 0f61b9176..434783ab7 100644 --- a/.abcd/work/issues/open/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md +++ b/.abcd/work/issues/resolved/iss-2608270908348042-five-delimiter-compare-variants-remain-beside-the-canonical.md @@ -7,7 +7,15 @@ category: "tech-debt" source: "agent-finding" found_during: "issue-sweep-2026-08-27" found_at: "internal/core/frontmatter/frontmatter.go" +resolution: "The private delimiter walks route through frontmatter.Close, the new CloseAfter or IsDelimiter; the deliberate differences (memory's indented opener, the transcript store's own byte-exact format, the reading exclusion floor) are commented and allowlisted, and TestNoPrivateDelimiterCompare fails on a new private three-dash literal outside that allowlist. The mid-file ZWNBSP close in the re-verification note is refused by record-lint through the issue store's reader-parity leg, pinned by a case in TestRecordSchemaAgreesWithTheLedgerReader." +impact: internal +resolved_by: + commit: "2b6e0bf8e" --- five delimiter-compare variants remain beside the canonical frontmatter.IsDelimiter: gate-side TrimSpace compares in lint and glossary accept an indented delimiter the canonical rule refuses, intent and changelog carry tolerant local copies, memory keeps its own close predicate, and site tests a bare HasPrefix — one consolidation pass onto the canonical predicate closes the family Re-verification note: a record whose block is closed only by a mid-file ZWNBSP delimiter is capture-refused but frontmatter.Fields-green, and no lint rule runs the strict ledger parser — record-lint passes what capture refuses until the consolidation lands. + +## Grounds + +- pursued: every reader of a record's frontmatter judges its delimiters by one rule, so a gate and its reader read the same block; a committed record whose block closes differently to a gate than to Fields, or a new private three-dash compare that the detector does not name, would show it wrong From 674ece9d85281339a903b268e928c1534a8712bd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:14 +0100 Subject: [PATCH 52/78] =?UTF-8?q?chore:=20resolve=20iss-2608270655499478?= =?UTF-8?q?=20=E2=80=94=20the=20ledger=20gate=20refuses=20a=20block=20sequ?= =?UTF-8?q?ence=20as=20the=20reader=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608270655499478 Assisted-by: Claude:claude-opus-5-5 --- ...e-parser-divergence-remainder-block-seq.md | 12 ----------- ...e-parser-divergence-remainder-block-seq.md | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md create mode 100644 .abcd/work/issues/resolved/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md diff --git a/.abcd/work/issues/open/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md b/.abcd/work/issues/open/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md deleted file mode 100644 index 72fb58b87..000000000 --- a/.abcd/work/issues/open/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-2608270655499478" -slug: "record-lint-vs-capture-parser-divergence-remainder-block-seq" -severity: "minor" -category: "tech-debt" -source: "agent-finding" -found_during: "security-cut-agent-flagged-siblings-2026-08-27" -found_at: "internal/core/frontmatter" ---- - -record-lint vs capture parser divergence remainder: block-sequence frontmatter fields are legitimate and used in 21+ intent/adr records but only capture's strict ledger parser rejects them, so a correct fix is store-scoped (share capture's typed strict parser through the canonical frontmatter package) rather than a universal rejection. The duplicate-key and space-before-colon halves of #357 are fixed; this block-sequence remainder is the follow-up. Flagged by the lint-integrity fix agent. \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md b/.abcd/work/issues/resolved/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md new file mode 100644 index 000000000..a0e85c16b --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2608270655499478-record-lint-vs-capture-parser-divergence-remainder-block-seq.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-2608270655499478" +slug: "record-lint-vs-capture-parser-divergence-remainder-block-seq" +severity: "minor" +category: "tech-debt" +source: "agent-finding" +found_during: "security-cut-agent-flagged-siblings-2026-08-27" +found_at: "internal/core/frontmatter" +resolution: "Overtaken by 35600e968: record_schema's issue-store reader-parity leg asks capture's strict ledger reader itself (capture.ReadRefusal, registered by the front doors), so a block-sequence field in an issue record is refused by the gate exactly as the reader refuses it, while the intent and ADR stores, whose readers take block sequences, are untouched: the store-scoped fix the record asks for. Pinned by TestRecordSchemaRefusesAnIssueBlockSequenceAsTheReaderDoes in 8f0cf5f42." +impact: internal +resolved_by: + commit: "35600e968" +--- + +record-lint vs capture parser divergence remainder: block-sequence frontmatter fields are legitimate and used in 21+ intent/adr records but only capture's strict ledger parser rejects them, so a correct fix is store-scoped (share capture's typed strict parser through the canonical frontmatter package) rather than a universal rejection. The duplicate-key and space-before-colon halves of #357 are fixed; this block-sequence remainder is the follow-up. Flagged by the lint-integrity fix agent. + +## Grounds + +- pursued: record-lint refuses an issue record with a block-sequence field because capture's reader does, and no other store; an issue record carrying a block sequence that lints green, or an intent or ADR block sequence the gate starts refusing, would show it wrong From 1ebed1fa0ea9be58409f8b36d20707b20780f300 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:24 +0100 Subject: [PATCH 53/78] test(record): an issue with broken frontmatter is skipped, not "not found" iss-263 asked that `abcd iss-N` stop answering "not found in the issue ledger" for an issue file that is present but unparseable. 4323948fc made describeIssue consult the reader's skipped roster and fault with the file and the skip reason; its tests use an unknown key and a retired property. This pins the record's own shape, a block that is never closed: the fault wraps ErrSkippedRecord and names the file and the parse error (red with the skipped-roster branch switched off on a copy). Refs: iss-263 Assisted-by: Claude:claude-opus-5-5 --- internal/core/record/record_test.go | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/core/record/record_test.go b/internal/core/record/record_test.go index 9c2cae0e1..42acbe8f4 100644 --- a/internal/core/record/record_test.go +++ b/internal/core/record/record_test.go @@ -984,3 +984,41 @@ func TestDescribeReframeReportsOccasionAndFingerprints(t *testing.T) { } assertZeroWrites(t, repo, before) } + +// TestDescribeUnparseableIssueIsNotNotFound pins iss-263's own shape: an issue +// file whose frontmatter is broken (here, never closed) is present in a status +// folder, so `abcd iss-N` names the file and the parse error rather than +// answering "not found in the issue ledger". +func TestDescribeUnparseableIssueIsNotNotFound(t *testing.T) { + repo := t.TempDir() + res, err := capture.Capture(capture.CaptureRequest{ + RepoRoot: repo, Text: "a record whose frontmatter will break", Severity: capture.SeverityMinor, + Category: "observation", Source: "user-observation", FoundDuring: "t", Slug: "broken-block", + }) + if err != nil { + t.Fatal(err) + } + abs := res.Path + if !filepath.IsAbs(abs) { + abs = filepath.Join(repo, abs) + } + raw, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + // Drop the closing delimiter: the block is never terminated. + head, body, ok := strings.Cut(string(raw), "\n---\n") + if !ok { + t.Fatalf("fixture has no closing delimiter: %q", raw) + } + if err := os.WriteFile(abs, []byte(head+"\n"+body), 0o644); err != nil { + t.Fatal(err) + } + _, err = Describe(repo, res.ID) + if err == nil || strings.Contains(err.Error(), "not found") || !errors.Is(err, ErrSkippedRecord) { + t.Fatalf("an unparseable issue must fault as skipped, not as not found: %v", err) + } + if !strings.Contains(err.Error(), filepath.Base(abs)) || !strings.Contains(err.Error(), "not terminated") { + t.Fatalf("the fault must name the file and the parse error: %v", err) + } +} From c61898eee4368cbf9dabb0691c2f9787f4fe01e2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:30 +0100 Subject: [PATCH 54/78] =?UTF-8?q?chore:=20resolve=20iss-263=20=E2=80=94=20?= =?UTF-8?q?an=20unparseable=20issue=20names=20its=20file=20and=20parse=20e?= =?UTF-8?q?rror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-263 Assisted-by: Claude:claude-opus-5-5 --- ...ch-unparseable-issue-reads-as-not-found.md | 12 ----------- ...ch-unparseable-issue-reads-as-not-found.md | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) delete mode 100644 .abcd/work/issues/open/iss-263-dispatch-unparseable-issue-reads-as-not-found.md create mode 100644 .abcd/work/issues/resolved/iss-263-dispatch-unparseable-issue-reads-as-not-found.md diff --git a/.abcd/work/issues/open/iss-263-dispatch-unparseable-issue-reads-as-not-found.md b/.abcd/work/issues/open/iss-263-dispatch-unparseable-issue-reads-as-not-found.md deleted file mode 100644 index 4eb7f5096..000000000 --- a/.abcd/work/issues/open/iss-263-dispatch-unparseable-issue-reads-as-not-found.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -id: "iss-263" -slug: "dispatch-unparseable-issue-reads-as-not-found" -severity: "nitpick" -category: "ux" -source: "impl-review" -found_during: "spc-26 build, ruthless-reviewer note" -found_at: "internal/core/record/record.go" ---- - -describeIssue discards ListResult.Skipped, so an issue file that exists but is unparseable (broken frontmatter) makes abcd iss-N report 'not found in the issue ledger' — a diagnostic that misleads about a record physically present in a status dir. Surface the skip roster in the fault: 'iss-N present but unreadable at : '. \ No newline at end of file diff --git a/.abcd/work/issues/resolved/iss-263-dispatch-unparseable-issue-reads-as-not-found.md b/.abcd/work/issues/resolved/iss-263-dispatch-unparseable-issue-reads-as-not-found.md new file mode 100644 index 000000000..1a4da49fa --- /dev/null +++ b/.abcd/work/issues/resolved/iss-263-dispatch-unparseable-issue-reads-as-not-found.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-263" +slug: "dispatch-unparseable-issue-reads-as-not-found" +severity: "nitpick" +category: "ux" +source: "impl-review" +found_during: "spc-26 build, ruthless-reviewer note" +found_at: "internal/core/record/record.go" +resolution: "Overtaken by 4323948fc: describeIssue consults the skipped roster capture.List returns and faults with ErrSkippedRecord naming the file and the reader's own parse error, never 'not found'. The record's own shape, a never-closed block, is pinned by TestDescribeUnparseableIssueIsNotNotFound in 1ebed1fa0." +impact: internal +resolved_by: + commit: "4323948fc" +--- + +describeIssue discards ListResult.Skipped, so an issue file that exists but is unparseable (broken frontmatter) makes abcd iss-N report 'not found in the issue ledger' — a diagnostic that misleads about a record physically present in a status dir. Surface the skip roster in the fault: 'iss-N present but unreadable at : '. + +## Grounds + +- pursued: abcd iss-N on a present but unparseable issue file names the file and the parse error; a broken-frontmatter issue that answers not found in the issue ledger would show it wrong From 63c8a613ee655bb42cb21d3cbe2591a23c10ba23 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:40 +0100 Subject: [PATCH 55/78] fix(record): an empty superseded_by names no successor, as the gate reads it record-lint's supersession leg reads `superseded_by: []`, `{}` and `!!null` as absent (frontmatter.IsEmptyValue), while Describe gated the link on the YAML null set alone and rendered a successor link whose target was a bracket pair. The ADR and intent pages now ask IsEmptyValue, the gate's own emptiness question, so one value gets one answer. No committed record spells an empty collection or an empty node there, so no page changes. Refs: iss-2608301744300631 Assisted-by: Claude:claude-opus-5-5 --- internal/core/record/record.go | 10 +++++++-- internal/core/record/record_test.go | 33 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/core/record/record.go b/internal/core/record/record.go index 2a58fdbae..177e345b9 100644 --- a/internal/core/record/record.go +++ b/internal/core/record/record.go @@ -264,7 +264,10 @@ func describeIntent(repoRoot, id string) (Description, error) { if len(it.RelatedIssues) > 0 { d.Links["related_issues"] = strings.Join(it.RelatedIssues, ", ") } - if sup := fields["superseded_by"].Value; sup != "" && !frontmatter.IsNull(sup) { + // Absence is frontmatter.IsEmptyValue's, the question record-lint's + // supersession gate asks, so `[]`, `{}` and `!!null` name no successor here + // either (iss-2608301744300631). + if sup := fields["superseded_by"].Value; !frontmatter.IsEmptyValue(sup) { d.Links["superseded_by"] = sup } if it.Held != "" { @@ -567,7 +570,10 @@ func describeADR(repoRoot, id string) (Description, error) { Path: rel, Links: map[string]string{}, } - if sup := fields["superseded_by"].Value; sup != "" && !frontmatter.IsNull(sup) { + // One emptiness question with the supersession gate: an empty collection + // or an empty node is no successor, never a link to a bracket pair + // (iss-2608301744300631). + if sup := fields["superseded_by"].Value; !frontmatter.IsEmptyValue(sup) { d.Links["superseded_by"] = sup } d.NextMoves = []string{"none — decisions are read"} diff --git a/internal/core/record/record_test.go b/internal/core/record/record_test.go index 42acbe8f4..5881ad14f 100644 --- a/internal/core/record/record_test.go +++ b/internal/core/record/record_test.go @@ -1022,3 +1022,36 @@ func TestDescribeUnparseableIssueIsNotNotFound(t *testing.T) { t.Fatalf("the fault must name the file and the parse error: %v", err) } } + +// TestDescribeReadsAnEmptySupersededByAsNoSuccessor: record-lint's supersession +// gate reads an empty collection or an empty node in superseded_by as absent, so +// the dispatcher asks the same emptiness question and renders no successor link +// for it — one value, one answer (iss-2608301744300631). A populated value is +// still the link. The intent page reads the field the same way. +func TestDescribeReadsAnEmptySupersededByAsNoSuccessor(t *testing.T) { + for _, tc := range []struct{ value, want string }{ + {"[]", ""}, {"{}", ""}, {"!!null", ""}, {"[ ]", ""}, {"~", ""}, {"adr-7", "adr-7"}, + } { + t.Run(tc.value, func(t *testing.T) { + repo := t.TempDir() + write(t, repo, ".abcd/development/decisions/adrs/0040-three-verbs.md", + "---\nid: adr-40\nstatus: accepted\nsuperseded_by: "+tc.value+"\n---\n\n# A decision\n") + d, err := Describe(repo, "adr-40") + if err != nil { + t.Fatal(err) + } + if got := d.Links["superseded_by"]; got != tc.want { + t.Fatalf("adr superseded_by link = %q, want %q", got, tc.want) + } + write(t, repo, ".abcd/development/intents/superseded/itd-5-old.md", + "---\nid: itd-5\nslug: old\nspec_id: null\nkind: standalone\nsuperseded_by: "+tc.value+"\n---\n\n# O\n") + d, err = Describe(repo, "itd-5") + if err != nil { + t.Fatal(err) + } + if got := d.Links["superseded_by"]; got != tc.want { + t.Fatalf("intent superseded_by link = %q, want %q", got, tc.want) + } + }) + } +} From 0e798e6d315f11d360dab7c303db8179ccd86502 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:04:41 +0100 Subject: [PATCH 56/78] =?UTF-8?q?chore:=20resolve=20iss-2608301744300631?= =?UTF-8?q?=20=E2=80=94=20one=20emptiness=20question=20for=20superseded=5F?= =?UTF-8?q?by?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2608301744300631 Assisted-by: Claude:claude-opus-5-5 --- ...collection-in-superseded-by-is-an-absence-to-the-ga.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md (74%) diff --git a/.abcd/work/issues/open/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md b/.abcd/work/issues/resolved/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md similarity index 74% rename from .abcd/work/issues/open/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md rename to .abcd/work/issues/resolved/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md index de1623f44..5ebfd2f78 100644 --- a/.abcd/work/issues/open/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md +++ b/.abcd/work/issues/resolved/iss-2608301744300631-an-empty-collection-in-superseded-by-is-an-absence-to-the-ga.md @@ -7,6 +7,10 @@ category: "bug" source: "impl-review" found_during: "itd-189-round-5-build" found_at: "internal/core/record/record.go (describeADR)" +resolution: "Describe asks frontmatter.IsEmptyValue, the emptiness question record-lint's supersession gate asks through isAbsentValue, for the ADR and the intent page alike, so an empty collection or an empty node in superseded_by is no successor to both readers." +impact: fix +resolved_by: + commit: "63c8a613e" --- an empty collection in superseded_by is an absence to the gate and a rendered link to record Describe so the two readers disagree about whether the record names a successor @@ -33,3 +37,7 @@ disagreement is between the two predicates and not between the two spellings. Remedy: give the dispatcher the same emptiness question the gate asks, so one value gets one answer — not a second special case in `isAbsentValue`, which would leave the dispatcher rendering `[]` as a link. + +## Grounds + +- pursued: the dispatcher and the gate agree on whether a record names a successor; an ADR or intent whose superseded_by the gate reads as absent but abcd renders as a link, as TestDescribeReadsAnEmptySupersededByAsNoSuccessor asserts for [], {}, !!null, [ ] and ~, would show it wrong From d6871271310828e3da4516bef9bc9ced6daccf13 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:16:42 +0100 Subject: [PATCH 57/78] fix(scripts): a revert withdraws a declaration only by its own deed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revert withdrawal honoured a "This reverts commit " line on the message alone, so a fix with no ledger move followed by one README line under a hand-written revert line withdrew its Resolves: and passed RS001 — a fix without its resolution, on one added message line. A live revert now withdraws only the ids whose record its OWN diff (git diff-tree ^ , renames off) takes back out of resolved/ or wontfix/, or out of shipped/ for Delivers:. A git revert of a real resolution or delivery still withdraws; a line over a commit that moves nothing, or moves a different record, withdraws nothing, and the declaration is judged as before. AGENTS.md's RS001 and RS005 bullets state the rule. Refs: iss-2609240646533487 Assisted-by: Claude:claude-opus-5-5 --- AGENTS.md | 9 ++- scripts/check-issue-resolution-cases.sh | 64 ++++++++++++++++++ scripts/check-issue-resolution.sh | 87 ++++++++++++++++++++----- 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 276748883..657cf4bc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -346,7 +346,10 @@ irreversible; guessing downward costs nothing.** record satisfies nothing. Resolution is deliberately not a post-merge step: a step that happens after the merge is the one that gets forgotten, and a fixed-but-open issue leaves no marker to find it by. Resolving without a trailer stays legal — a - stale issue closed on its own merits has no fixing commit to name. + stale issue closed on its own merits has no fixing commit to name. A `git + revert` later in the same range withdraws a `Resolves:` only for a record its + own diff takes back out of `resolved/` or `wontfix/`; a "This reverts commit" + line over a commit that moves no record withdraws nothing. - **A change that delivers a planned intent closes its spec in the same change**: `go run ./cmd/abcd spec close ` moves the spec to `closed/` and, as its close-hook, the intent from `planned/` to `shipped/`. Nothing @@ -365,7 +368,9 @@ irreversible; guessing downward costs nothing.** and there is no default: a record that does not already declare it takes `--impact additive|breaking|fix` on the close, and a close with neither is refused before anything moves. Same shape as the issue rule above: the step - that happens after the merge is the one that gets forgotten. + that happens after the merge is the one that gets forgotten. A revert + withdraws a `Delivers:` on the same terms, only for an intent its own diff + takes back out of `shipped/`. - **A `resolved_by.commit` stamp names a commit that is actually reachable.** `abcd capture resolve --commit` is shape-checked only, so a wrong sha reads exactly like a right one; RS002/RS003 check reachability instead. Note the diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 481f69aad..68a79abfa 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -1283,6 +1283,70 @@ Resolves: iss-999" git -C "$d" revert --no-edit HEAD >/dev/null expect pass "$d" "RS001 a resolution reverted in the same range is withdrawn" -- commits main HEAD +# A wontfix/ disposition reverted by git takes the record back out too. +d="$(newrepo rs001-reverted-wontfix)" +git -C "$d" mv "$ISS_DIR/open/iss-999-a-fixture.md" "$ISS_DIR/wontfix/iss-999-a-fixture.md" +git -C "$d" commit -qm "chore: wontfix the fixture + +Resolves: iss-999" +git -C "$d" revert --no-edit HEAD >/dev/null +expect pass "$d" "RS001 a wontfix disposition reverted in the same range is withdrawn" -- commits main HEAD + +# A withdrawal is judged on the deed, never on the line. The revert line is text +# anyone can type: a fix with no ledger move, then one README line under a +# hand-written "This reverts commit" naming it, withdrew the `Resolves:` — a fix +# without its resolution passing RS001 on one added message line. The revert +# takes no record out of a terminal folder, so the declaration stands. +d="$(newrepo rs001-handwritten-revert)" +echo "the fix" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +echo "an unrelated line" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "Revert \"fix: something\" + +This reverts commit $(git -C "$d" rev-parse HEAD)." +expect_refusal_naming "$d" "RS001 a hand-written revert line over a commit that reverts nothing withdraws nothing" \ + "declares 'Resolves: iss-999', but iss-999 does not enter" -- commits main HEAD + +# The deed must be the declared record's: a hand-written revert that takes a +# DIFFERENT record out of resolved/ withdraws nothing for iss-999. +d="$(newrepo rs001-handwritten-revert-other-record)" +git -C "$d" checkout -q main +printf -- '---\nschema_version: 1\nid: "iss-998"\n---\nAnother fixture issue.\n' >"$d/$ISS_DIR/resolved/iss-998-other.md" +git -C "$d" add -A +git -C "$d" commit -qm "chore: resolve another record" +git -C "$d" checkout -q -B work main +echo "the fix" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +git -C "$d" rm -q "$ISS_DIR/resolved/iss-998-other.md" +git -C "$d" commit -qm "Revert \"fix: something\" + +This reverts commit $(git -C "$d" rev-parse HEAD)." +expect_refusal_naming "$d" "RS001 a hand-written revert taking another record out withdraws nothing for the declared one" \ + "declares 'Resolves: iss-999', but iss-999 does not enter" -- commits main HEAD + +# The RS005 twin of the hand-written line: a delivery that shipped nothing is not +# withdrawn by a commit that takes nothing out of shipped/. +d="$(newrepo_intents rs005-handwritten-revert)" +echo "the thing" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +echo "an unrelated line" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "Revert \"feat: build the thing\" + +This reverts commit $(git -C "$d" rev-parse HEAD)." +expect_refusal_naming "$d" "RS005 a hand-written revert line over a commit that reverts nothing withdraws nothing" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD + # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary # case (a trailer whose record stays where it was); each refusal is normalised by diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index ef548b8ab..ebb7475ff 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -568,18 +568,66 @@ revert_pairs() { done } -# withdrawn_by prints the commit that withdraws sha's declarations — a revert of -# it in the range that is not itself reverted there (a revert of a revert -# reinstates) — and returns 0, or returns 1 when nothing withdraws it. +# withdrawn_by prints every commit that reverts sha in the range and is not +# itself reverted there (a revert of a revert reinstates), and returns 0, or +# returns 1 when nothing does. Naming a commit is only the claim; which of its +# declarations the revert withdraws is decided by what the revert DID +# (ids_taken_out). withdrawn_by() { - local sha="$1" pairs="$2" r + local sha="$1" pairs="$2" r found=1 for r in $(printf '%s\n' "$pairs" | awk -v s="$sha" '$2 == s { print $1 }'); do if ! withdrawn_by "$r" "$pairs" >/dev/null; then printf '%s\n' "$r" - return 0 + found=0 fi done - return 1 + return "$found" +} + +# ids_taken_out prints the records commit r's OWN diff takes back out of their +# terminal folder: an iss-N that leaves resolved/ or wontfix/ and enters neither, +# or a canonical itd-N that leaves shipped/ and does not re-enter it. This is the +# deed a withdrawal is judged on. The "This reverts commit" line is text anyone +# can type, and honoured on the text alone a commit that reverts nothing — one +# README line under a hand-written revert line — withdrew a `Resolves:` whose +# record never moved, so a fix without its resolution passed RS001. A `git +# revert` of a real resolution or delivery moves the record back out; a +# hand-written line over a commit that moves nothing takes nothing out, and the +# declaration stands to be judged. A move between terminal folders, or a reslug +# inside one, leaves the record terminal and takes nothing out. Renames are off, +# so every move reads as a delete plus an add and the two halves are paired by id. +ids_taken_out() { + local r="$1" out rc=0 + out="$(git diff-tree --no-commit-id --name-status -r --no-renames "$r^" "$r" -- \ + "$ISSUES_DIR/resolved" "$ISSUES_DIR/wontfix" "$INTENTS_DIR/shipped" 2>&1)" || rc=$? + if [ "$rc" -ne 0 ]; then + echo "check-issue-resolution: git diff-tree failed for ${r:0:12} (exit $rc) — refusing rather than reporting a vacuous pass:" >&2 + echo "$out" >&2 + exit 2 + fi + printf '%s\n' "$out" | while IFS=$'\t' read -r status path; do + local id="" + case "$status" in + D | A) ;; + *) continue ;; + esac + case "$path" in + "$ISSUES_DIR/"*) id="$(basename "$path" | grep -oE '^iss-[0-9]+' || true)" ;; + "$INTENTS_DIR/shipped/"*) id="$(canon_itd "$(basename "$path" | grep -oE '^itd-[0-9]+' || true)")" ;; + esac + [ -n "$id" ] && printf '%s %s\n' "$status" "$id" + done | awk '$1 == "D" { d[$2] = 1 } $1 == "A" { a[$2] = 1 } END { for (k in d) if (!(k in a)) print k }' | sort -u +} + +# withdrawn_note reports, and returns 0 for, a declared id that a live revert of +# its commit takes back out of the terminal folder; it returns 1 for any other. +withdrawn_note() { + local rule="$1" sha="$2" decl="$3" id="$4" withdrawn="$5" w + [ -n "$withdrawn" ] || return 1 + w="$(awk -v id="$id" '$1 == id && !seen { print $2; seen = 1 }' <<<"$withdrawn")" + [ -n "$w" ] || return 1 + echo "check-issue-resolution: $rule commit ${sha:0:12}'s '$decl' is withdrawn: ${w:0:12} reverts it and takes $id back out of its terminal folder" + return 0 } check_pr() { @@ -652,8 +700,10 @@ check_commits() { shipped="$(ids_entering_shipped "$base" "$head" | sort -u)" # A declaration a later commit of the range reverts is withdrawn: its - # `Resolves:` and `Delivers:` lines are not held to a move the revert undid. - # RS004 still reads the message — a withdrawn commit named what it named. + # `Resolves:` and `Delivers:` ids are not held to a move the revert undid — + # but only the ids whose record the revert's own diff takes back out of its + # terminal folder (ids_taken_out). RS004 still reads the message — a + # withdrawn commit named what it named. local reverts reverts="$(revert_pairs "$base" "$head" "$range")" @@ -679,14 +729,17 @@ check_commits() { msg="$(git show -s --format='%B' "$sha")" check_mentions "commit ${sha:0:12}" "$msg" "$(declared_ids "$msg")" scanned=$((scanned + 1)) - if [ -n "$reverts" ]; then - local withdrawer - if withdrawer="$(withdrawn_by "$sha" "$reverts")"; then - if grep -qE "$TRAILER_RE|$DELIVERS_LOOSE_RE" <<<"$msg"; then - echo "check-issue-resolution: RS001/RS005 commit ${sha:0:12} is reverted in this range by ${withdrawer:0:12}, so its Resolves:/Delivers: declarations are withdrawn" - fi - continue - fi + # withdrawn holds " " for each record a live revert of + # this commit takes back out; an id absent from it is judged as usual. + local withdrawn="" withdrawers w + if [ -n "$reverts" ] && withdrawers="$(withdrawn_by "$sha" "$reverts")"; then + for w in $withdrawers; do + local taken + taken="$(ids_taken_out "$w")" || exit 2 + [ -n "$taken" ] || continue + withdrawn="$withdrawn$(printf '%s\n' "$taken" | sed "s/\$/ $w/") +" + done fi while IFS= read -r line; do # RS005 — a declared delivery must ship the intent. Judged on the same @@ -707,6 +760,7 @@ check_commits() { for raw in $(printf '%s\n' "$line" | grep -oE 'itd-[0-9]+'); do local cid cid="$(canon_itd "$raw")" + withdrawn_note RS005 "$sha" "Delivers: $cid" "$cid" "$withdrawn" && continue delivered="$delivered $cid" check_delivery "$sha" "$cid" "$base" "$head" "$shipped" "$behind" "$mb" done @@ -719,6 +773,7 @@ check_commits() { # drift this rule exists to stop, reopened by a comma. local id for id in $(printf '%s\n' "$line" | grep -oE 'iss-[0-9]+'); do + withdrawn_note RS001 "$sha" "Resolves: $id" "$id" "$withdrawn" && continue declared="$declared $id" grep -qx "$id" <<<"$closed" && continue local head_path base_path base_status From a24c792e8d01b86a672670df210144851b234e99 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:16:53 +0100 Subject: [PATCH 58/78] fix(scripts): entering a terminal folder is judged at the merge base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entering test read the BASE's listing, so an honest re-disposition on the branch — wontfix/ -> resolved/ of a record the base still holds in wontfix/ — read as "already terminal" and a true Resolves: was refused with a diagnosis naming a resolution before this commit. A landing now enters unless the MERGE BASE held the record in the very folder it lands in: a stale base-side move between terminal folders, a base-side reslug, and the same move rewritten past rename detection all land where the record sat at the fork and enter nothing, as before; a branch's own wontfix/ -> resolved/ lands somewhere new and enters. The merge base is computed before the entering test, which now shares it with the stale-branch diagnoses. The shipped/ twin has one terminal folder and is unchanged. Refs: iss-2609012047551175 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 16 ++++++ scripts/check-issue-resolution.sh | 65 ++++++++++++++++++------- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 68a79abfa..358b09f71 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -410,6 +410,22 @@ git -C "$d" commit -qm "chore: reclassify and rewrite" git -C "$d" checkout -q work expect fail "$d" "RS001 a base-side move rewritten past rename detection does not satisfy a stale trailer" -- commits main HEAD +# The fork is the reference, not the base's tip: an honest re-disposition on the +# branch — wontfix/ -> resolved/ of a record the base still holds in wontfix/ — +# lands in a folder the record did not sit in at the merge base, so it enters, +# and its true trailer passes. Keyed on the base's listing, the record read as +# "already terminal" and the trailer was refused as naming an earlier resolution. +d="$(newrepo rs001-branch-redisposes-wontfix)" +git -C "$d" checkout -q main +git -C "$d" mv "$ISS_DIR/open/iss-999-a-fixture.md" "$ISS_DIR/wontfix/iss-999-a-fixture.md" +git -C "$d" commit -qm "chore: wontfix iss-999" +git -C "$d" checkout -q -B work main +git -C "$d" mv "$ISS_DIR/wontfix/iss-999-a-fixture.md" "$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" commit -qm "fix: something after all + +Resolves: iss-999" +expect pass "$d" "RS001 a branch's own wontfix/ -> resolved/ move enters resolved/" -- commits main HEAD + # --- RS001's stale-branch split asks the merge base (iss-2609012047566360) ---- # # Terminal at the merge base, then merely EDITED on the base's side: the edit is diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index ebb7475ff..a090036de 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -251,21 +251,33 @@ usage() { # caught here. A record that only LEAVES open/ (a bare delete) enters nothing and # is deliberately absent, so RS001 refuses a trailer that merely deletes. # -# ENTERING means from outside: a record the base already holds in a terminal -# folder enters nothing, whatever the diff shows (iss-2609012047551175). Keyed on -# the destination alone, a move BETWEEN terminal folders counted — so a stale -# branch whose base had since moved the record resolved/ -> wontfix/, or reslugged -# it inside resolved/, read the two-dot diff's rename back into place as this -# branch's resolution, and a trailer satisfied by a move it did not make passed -# silently. The base's own listing is the test rather than the rename's source, -# because a move rewritten past rename detection arrives as a plain add and has -# no source to read. Such a trailer falls through to RS001's diagnosis instead. +# ENTERING means from outside: a record the MERGE BASE already holds in the very +# terminal folder it lands in enters nothing, whatever the diff shows +# (iss-2609012047551175). Keyed on the destination alone, a move BETWEEN terminal +# folders counted — so a stale branch whose base had since moved the record +# resolved/ -> wontfix/, or reslugged it inside resolved/, read the two-dot +# diff's rename back into place as this branch's resolution, and a trailer +# satisfied by a move it did not make passed silently. In both shapes the record +# lands where it already sat at the fork, so neither enters. +# +# The fork, never the base's tip, is the reference: an honest re-disposition on +# the branch — wontfix/ -> resolved/ of a record the base still holds in +# wontfix/ — lands in a folder the record did not sit in at the fork, and is +# this branch's own move. Asked of the base's tip, the record was "already +# terminal" and the true trailer was refused with a diagnosis naming a +# resolution before this commit. The merge base's listing is the test rather +# than the rename's source, because a move rewritten past rename detection +# arrives as a plain add and has no source to read. A trailer that enters +# nothing falls through to RS001's diagnosis. With no merge base (unrelated +# histories) nothing sat anywhere at the fork, and every landing enters. ids_entering_closed() { - local base="$1" head="$2" terminal_at_base + local base="$1" head="$2" mb="$3" terminal_at_mb="" # `|| exit 2`: this runs inside the caller's command substitution, where # errexit is cleared, so a failed listing must end the subshell itself for # pipefail to carry it out. - terminal_at_base="$(terminal_ids "$base" "$ISSUES_DIR/resolved" "$ISSUES_DIR/wontfix" 'iss-[0-9]+')" || exit 2 + if [ -n "$mb" ]; then + terminal_at_mb="$(terminal_folders "$mb")" || exit 2 + fi git diff --name-status --find-renames "$base".."$head" -- "${STATUS_PATHSPECS[@]}" | while IFS=$'\t' read -r status path dest; do local landed="" id @@ -277,13 +289,28 @@ ids_entering_closed() { "$ISSUES_DIR/resolved/"* | "$ISSUES_DIR/wontfix/"*) id="$(basename "$landed" | grep -oE '^iss-[0-9]+' || true)" [ -n "$id" ] || continue - grep -qx "$id" <<<"$terminal_at_base" && continue + grep -qx "$id $(status_of "$landed")" <<<"$terminal_at_mb" && continue printf '%s\n' "$id" ;; esac done } +# terminal_folders prints "iss-N " for every record ref holds in +# resolved/ or wontfix/ — the folder is what ids_entering_closed compares a +# landing against. rc-checked as terminal_ids is, for the same reason. +terminal_folders() { + local ref="$1" listing rc=0 + listing="$(git ls-tree -r --name-only "$ref" -- "$ISSUES_DIR/resolved" "$ISSUES_DIR/wontfix" 2>&1)" || rc=$? + if [ "$rc" -ne 0 ]; then + echo "check-issue-resolution: git ls-tree failed at $ref (exit $rc) — refusing rather than reporting a vacuous pass:" >&2 + echo "$listing" >&2 + exit 2 + fi + printf '%s\n' "$listing" | sed "s|^$ISSUES_DIR/||" | + awk -F/ 'NF >= 2 && match($NF, /^iss-[0-9]+/) { print substr($NF, RSTART, RLENGTH) " " $1 }' | sort -u +} + # terminal_ids prints, one per line, the id (matched by the ERE in $4, anchored # at the basename's start) of every record ref holds under the terminal folders # $2 and $3 ($3 may be empty). The listing is rc-checked: a git failure must not @@ -683,8 +710,15 @@ check_commits() { # otherwise vanish from the ledger, its changelog line lost, with no other gate # to catch it. (A record that enters resolved/ while a copy stays in open/ is a # duplicate id, which record-lint's issue_id_unique refuses.) + # + # The fork point both the entering test and the stale-branch diagnoses ask + # about. None (unrelated histories) leaves it empty, and every record then + # reads as placed after the fork, which is where head..base puts all of + # base's history anyway. + local mb + mb="$(git merge-base "$base" "$head" 2>/dev/null || true)" local closed - closed="$(ids_entering_closed "$base" "$head" | sort -u)" + closed="$(ids_entering_closed "$base" "$head" "$mb" | sort -u)" # RS001 — a declared resolution must move the record. Every shape below is a # refusal; they differ in the diagnosis, and the diagnosis is what a reader @@ -710,11 +744,6 @@ check_commits() { local declared="" delivered="" local behind behind="$(git rev-list --count "$head".."$base")" - # The fork point the stale-branch diagnoses ask about. None (unrelated - # histories) leaves it empty, and every record then reads as placed after - # the fork, which is where head..base puts all of base's history anyway. - local mb - mb="$(git merge-base "$base" "$head" 2>/dev/null || true)" local scanned=0 while IFS= read -r sha; do [ -n "$sha" ] || continue From eff2eefdad5f18f5029d28518ac9b7d557565bdb Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:16:55 +0100 Subject: [PATCH 59/78] fix(scripts): RS006 reads a resolved record to the end RS006's resolution reader exited at the closing frontmatter delimiter. Under pipefail that hands git show a SIGPIPE on any record past one pipe buffer (64 KiB), and the unguarded assignment then ended the gate at exit 141 with no FAILED line. The reader now sets a flag at the delimiter and reads on. A case resolves a record of about 200 KiB. This makes true the RS006 half that iss-2609281314564762's resolution already claims. Refs: iss-2609281314564762 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 14 ++++++++++++++ scripts/check-issue-resolution.sh | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 358b09f71..1b6ca0967 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -494,6 +494,20 @@ git -C "$d" commit -qm "chore: touch many records $refs" expect pass "$d" "RS004 a declaration set past one pipe buffer is read whole" -- commits main HEAD +# RS006 reads the resolution of every record entering a terminal folder. A reader +# that stops at the closing delimiter hands git show a SIGPIPE on a record past +# one pipe buffer, and under pipefail the unguarded read ended the gate at exit +# 141 with no FAILED line. A resolved record of about 200 KiB must pass cleanly. +d="$(newrepo rs006-large-record)" +resolve_record "$d" +awk 'BEGIN { for (i = 0; i < 2500; i++) printf "Body line %d of a long resolved record, past one pipe buffer.\n", i }' \ + >>"$d/$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +expect pass "$d" "RS006 a resolved record past one pipe buffer is read whole" -- commits main HEAD + # --- RS002: a stamp added here must name a reachable commit ------------------ d="$(newrepo rs002-bad)" diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index a090036de..ee916c732 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -903,7 +903,11 @@ check_commits() { local rpath note name rpath="$(record_path "$head" "$id")" [ -n "$rpath" ] || continue - note="$(git show "$head:$rpath" 2>/dev/null | awk 'NR>1 && /^---$/{exit} /^resolution:/{print}')" + # Read to the end, never `exit` at the closing delimiter: under pipefail + # an early exit hands git show a SIGPIPE on any record past one pipe + # buffer (64 KiB), and this unguarded assignment then ends the gate at + # exit 141 with no FAILED line. + note="$(git show "$head:$rpath" 2>/dev/null | awk 'NR>1 && /^---$/{done=1} !done && /^resolution:/{print}')" while IFS= read -r name; do [ -n "$name" ] || continue rs006=$((rs006 + 1)) From cec6047b5903120a48c9f16952e851232e17dcab Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:17:03 +0100 Subject: [PATCH 60/78] fix(scripts): whitespace does not separate the bot word in a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configured-automation signal let whitespace separate the trailing bot/robot/automation word in a display name, so a person whose surname is Bot (`Jan Bot`) was refused — the one thing refuse-machines-not-an- allowlist rules out — and the paragraph called the word signal structural, which it is not. In the display name the word now stands alone or joins the rest by `-` or `_`: semantic-release-bot and ci_bot are still refused, and `Renovate Bot` at its default bot@renovateapp.com is refused by the local-part half. Cases: Jan Bot passes; semantic-release-bot and ci_bot by name alone, and Renovate Bot at its default address, are refused. AGENTS.md and CONTRIBUTING.md drop the stated over-reach and the structural wording for the word signal. Refs: iss-2609090951276167 Assisted-by: Claude:claude-opus-5-5 --- AGENTS.md | 13 +++++++---- CONTRIBUTING.md | 7 +++--- scripts/check-attribution-cases.sh | 21 +++++++++++++++-- scripts/check-attribution.sh | 37 ++++++++++++++++-------------- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 657cf4bc2..95e972ce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -409,11 +409,14 @@ irreversible; guessing downward costs nothing.** domain (`@anthropic.com`, `@openai.com`); the forge's own `[bot]` name suffix; a bot mailbox (`NNNN+name[bot]@users.noreply.github.com`, or `@dependabot.com`); and a trailing `bot`, `robot` or `automation` word ending - the name or the mailbox's local part (`semantic-release-bot`, `Renovate Bot`, - `ci_bot@`), standing alone so Talbot and `jean.bot@` pass. The last three are - structural rather than nominal, which is why a second automation lands in the - right place with no edit to the list; a machine configured with a person-shaped - name and mailbox stays out of reach, and the reviewer is the check on it. The + the name or the mailbox's local part, standing alone or joined by `-` or `_` + (`semantic-release-bot`, `ci_bot@`, and `Renovate Bot` at its default + `bot@renovateapp.com`), so Talbot, `jean.bot@` and a person named `Jan Bot` + pass. The `[bot]` suffix and the bot mailbox are structural rather than + nominal, which is why a second automation the forge stamps lands in the right + place with no edit to the list; the trailing word is a name shape, drawn + narrowly, and a machine configured with a person-shaped name and mailbox stays + out of reach, and the reviewer is the check on it. The sixth signal is checked in the AUTHOR role only: **any** address whose mailbox begins `noreply@` or `donotreply@` (with or without hyphens), whatever the host — it is not scoped to a vendor, because an address named for not being read names diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90a84b6b1..bd480af30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,9 +111,10 @@ disclosure, and never an authorship assertion for a tool. The rules: domain (`@anthropic.com`, `@openai.com`); the forge's own `[bot]` name suffix; a bot mailbox (`NNNN+name[bot]@users.noreply.github.com`, or `@dependabot.com`); and a trailing `bot`, `robot` or `automation` word ending - the name or the mailbox's local part (`semantic-release-bot`, `Renovate Bot`, - `ci_bot@`), standing alone so Talbot and `jean.bot@` pass (a person whose name - ends in the word Bot is refused too, the one over-reach the rule accepts). + the name or the mailbox's local part, standing alone or joined by `-` or `_` + (`semantic-release-bot`, `ci_bot@`, and `Renovate Bot` at its default + `bot@renovateapp.com`), so Talbot, `jean.bot@` and a person named `Jan Bot` + pass. The sixth applies to the AUTHOR role only: **any** address whose mailbox begins `noreply@` or `donotreply@`, with or without hyphens and whatever the host, not just a vendor's. Your forge privacy address diff --git a/scripts/check-attribution-cases.sh b/scripts/check-attribution-cases.sh index 9b445a8ed..2391e4d71 100755 --- a/scripts/check-attribution-cases.sh +++ b/scripts/check-attribution-cases.sh @@ -654,9 +654,20 @@ commit_as 'semantic-release-bot' '12345+semantic-release-bot@users.noreply.githu 'semantic-release-bot' '12345+semantic-release-bot@users.noreply.github.com' "$MSG_OK" commits_case reject "configured automation name at a forge no-reply address" -commit_as 'Renovate Bot' 'renovate@example.invalid' \ +# A display name with the word joined by `-` or `_` is a configured account's. +commit_as 'semantic-release-bot' 'release@example.invalid' \ REPPL human@example.invalid "$MSG_OK" -commits_case reject "automation display name ending in the word Bot" +commits_case reject "automation display name joined to the word bot by a hyphen" + +commit_as 'ci_bot' 'ci@example.invalid' \ + REPPL human@example.invalid "$MSG_OK" +commits_case reject "automation display name joined to the word bot by an underscore" + +# Renovate's default identity: whitespace does not separate in the display +# name, so its local part is what refuses it. +commit_as 'Renovate Bot' 'bot@renovateapp.com' \ + REPPL human@example.invalid "$MSG_OK" +commits_case reject "Renovate Bot at its default address, refused by the local part" commit_as 'Release' 'release_automation@example.invalid' \ REPPL human@example.invalid "$MSG_OK" @@ -675,6 +686,12 @@ commit_as 'Jean Abbott' 'jean.bot@example.invalid' \ 'Jean Abbott' 'jean.bot@example.invalid' "$MSG_OK" commits_case accept "human whose local part ends .bot" +# Whitespace does not separate the word in a display name: a person whose +# surname is Bot is a person. +commit_as 'Jan Bot' 'jan@example.invalid' \ + 'Jan Bot' 'jan@example.invalid' "$MSG_OK" +commits_case accept "human whose surname is Bot" + # --- Merge commits carry an identity too (iss-2609082001204831) ---------------- # The first hole: the commits arm walked `--no-merges`, so a merge commit's # identity was never read at all. 23f0a891 stands in main today, authored AND diff --git a/scripts/check-attribution.sh b/scripts/check-attribution.sh index 4aac3d40b..afe910e5d 100755 --- a/scripts/check-attribution.sh +++ b/scripts/check-attribution.sh @@ -158,28 +158,31 @@ MACHINE_MAIL_RE='\[bot\]@|@dependabot\.com$' # commits under a name it was configured with — semantic-release-bot at a forge # no-reply address, a self-hosted CI account, a forge whose app suffix is not # `[bot]` — matched none of the lists above and was judged a human -# (iss-2609090951276167). So the structural signal is widened to the SHAPE such -# configured names take: a trailing `bot`, `robot` or `automation` word, ending -# the display name or the mailbox's local part. -# -# The word must stand alone: at the start of the field, or after a separator. -# Talbot and Abbott pass; semantic-release-bot, ci_bot, `Renovate Bot` and -# `12345+semantic-release-bot@users.noreply.github.com` do not. In the local -# part the separators are `-`, `_` and the forge's `+`, and deliberately not -# `.`: `jean.bot@` is the ordinary shape of a person's address. In the display -# name whitespace separates too, which is the one stated over-reach — a person -# whose name's last word is Bot is refused, loudly and naming the identity — -# accepted because the failure it prevents is silent, the reason this rule -# exists. This is the line to revisit if such a contributor arrives. +# (iss-2609090951276167). So a second signal reads the SHAPE such configured +# names take: a trailing `bot`, `robot` or `automation` word, ending the display +# name or the mailbox's local part. It is a name shape, not a forge stamp, so +# unlike the two above it is nominal, and it is drawn narrowly for that reason. +# +# The word must stand alone: at the start of the field, or after a `-` or `_` +# joining it to the rest — the separators a configured account name uses and a +# person's name does not. Talbot and Abbott pass; semantic-release-bot, ci_bot +# and `12345+semantic-release-bot@users.noreply.github.com` do not. In the local +# part the forge's `+` separates too, and deliberately not `.`: `jean.bot@` is +# the ordinary shape of a person's address. In the display name WHITESPACE DOES +# NOT SEPARATE: `Jan Bot` is a person whose surname is Bot, and refusing a +# person's name is the thing refuse-machines-not-an-allowlist rules out. +# `Renovate Bot` is still refused, at its default address `bot@renovateapp.com`, +# by the local-part half. # # OUT OF REACH, said plainly: a machine whose configured name and mailbox look -# like a person's (`Release Manager `), a trailing word -# other than these three (`-ci`, `-agent`), and `name.bot@` addresses. Nothing -# structural separates those from a human; the reviewer reading the identity +# like a person's (`Release Manager `, or `Renovate Bot` +# reconfigured to `renovate@example.com`), a trailing word other than these +# three (`-ci`, `-agent`), and `name.bot@` addresses. Nothing in the identity +# separates those from a human; the reviewer reading the identity # is the check on them. internal/core/site/contributors.go's machineAddrRe does # not share this signal: the contributors page reads the published history, not # a pull request's range, and refusing is this gate's job alone. -MACHINE_NAME_WORD_RE='(^|[-_[:space:]])(bot|robot|automation)[[:space:]]*$' +MACHINE_NAME_WORD_RE='(^|[-_])(bot|robot|automation)[[:space:]]*$' MACHINE_LOCAL_WORD_RE='(^|[-_+])(bot|robot|automation)@' # A mailbox literally named for not being read. Refused in the AUTHOR role only, From cecae92fe4275289bc39dfd17a6d7e02cd12902f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:17:37 +0100 Subject: [PATCH 61/78] chore: correct four resolution notes to the gates as they now stand The notes are what later readers trust about how each fix was proved (RS006). Each is brought into line with the fix-round commits: iss-2609281314564762's RS006 half now names the commit that makes it true; iss-2609240646533487's withdrawal is judged on the revert's own diff; iss-2609012047551175's entering test reads the merge base; and iss-2609090951276167 no longer states the whitespace over-reach. Refs: iss-2609281314564762, iss-2609240646533487, iss-2609012047551175, iss-2609090951276167 Assisted-by: Claude:claude-opus-5-5 --- ...-entering-closed-in-scripts-check-issue-resolution-sh-acc.md | 2 +- ...6167-attribution-machine-signal-is-bot-suffix-shaped-only.md | 2 +- ...3487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md | 2 +- ...62-the-two-ci-gate-scripts-test-membership-and-matches-by.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md b/.abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md index e6aaeecfc..b10d76122 100644 --- a/.abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md +++ b/.abcd/work/issues/resolved/iss-2609012047551175-ids-entering-closed-in-scripts-check-issue-resolution-sh-acc.md @@ -9,7 +9,7 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" -resolution: "ids_entering_closed now counts an id as entering resolved/ or wontfix/ only when the base does not already hold it in a terminal folder (the base's listing, so a move rewritten past rename detection is caught too); ids_entering_shipped takes the same filter for shipped/. Proved by four cases in scripts/check-issue-resolution-cases.sh (base-side resolved->wontfix move, base-side reslug, the move rewritten past rename detection, the shipped/ reslug twin), each passing the old gate and refused by the new one." +resolution: "ids_entering_closed now counts an id as entering resolved/ or wontfix/ only when the merge base did not hold it in the very folder it lands in (the merge base's listing, so a move rewritten past rename detection is caught too, and a branch's own wontfix/ -> resolved/ move still enters: a24c792e8); ids_entering_shipped filters on the base's shipped/ listing. Proved by four cases in scripts/check-issue-resolution-cases.sh (base-side resolved->wontfix move, base-side reslug, the move rewritten past rename detection, the shipped/ reslug twin), each passing the old gate and refused by the new one, while a branch's own wontfix/ -> resolved/ move passes." impact: internal resolved_by: commit: "46ae88404" diff --git a/.abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md b/.abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md index b889cae34..69faabd1f 100644 --- a/.abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md +++ b/.abcd/work/issues/resolved/iss-2609090951276167-attribution-machine-signal-is-bot-suffix-shaped-only.md @@ -9,7 +9,7 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-attribution.sh" -resolution: "The attribution gate also refuses, in both roles, a trailing bot, robot or automation word standing alone at the end of the display name or the mailbox's local part, so a configured automation such as semantic-release-bot at a forge no-reply address is a machine; the comment names the shapes still out of reach and the one over-reach (a person whose name ends in the separate word Bot). Proved by cases in scripts/check-attribution-cases.sh: semantic-release-bot at a forge no-reply address, Renovate Bot, release_automation@ and a ci-robot committer are refused (all accepted by the previous gate), while Ada Talbot at a forge privacy address and jean.bot@ still pass." +resolution: "The attribution gate also refuses, in both roles, a trailing bot, robot or automation word ending the display name or the mailbox's local part, standing alone or joined by - or _ (and + in the local part), so a configured automation such as semantic-release-bot at a forge no-reply address is a machine; whitespace does not separate the word in a display name, so a person named Jan Bot passes (cec6047b5), and the comment names the shapes still out of reach. Proved by cases in scripts/check-attribution-cases.sh: semantic-release-bot at a forge no-reply address and by name alone, ci_bot, Renovate Bot at bot@renovateapp.com, release_automation@ and a ci-robot committer are refused, while Ada Talbot at a forge privacy address, jean.bot@ and Jan Bot pass." impact: internal resolved_by: commit: "bd3f55e68" diff --git a/.abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md b/.abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md index 864b231b7..72de23bed 100644 --- a/.abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md +++ b/.abcd/work/issues/resolved/iss-2609240646533487-revert-cannot-withdraw-a-delivers-trailer-from-rs005.md @@ -9,7 +9,7 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" -resolution: "A commit that a later commit of the same range reverts, in git revert's own words, has its Resolves: and Delivers: declarations withdrawn (RS001 and RS005 alike); a revert of that revert reinstates them, and a reverts line naming a commit outside the range, or not an ancestor of the revert, withdraws nothing. Proved by cases in scripts/check-issue-resolution-cases.sh: a reverted delivery and a reverted resolution pass (both refused by the previous gate), a revert of the revert is refused again, and a revert naming a commit outside the range withdraws nothing." +resolution: "A commit that a later commit of the same range reverts, in git revert's own words, has its Resolves: and Delivers: declarations withdrawn (RS001 and RS005 alike), but only for the ids whose record the revert's own diff takes back out of resolved/, wontfix/ or shipped/ (d68712713), so a hand-written reverts line over a commit that moves no record withdraws nothing; a revert of that revert reinstates them, and a reverts line naming a commit outside the range, or not an ancestor of the revert, withdraws nothing. Proved by cases in scripts/check-issue-resolution-cases.sh: a reverted delivery and a reverted resolution pass (both refused by the previous gate), a revert of the revert is refused again, a revert naming a commit outside the range withdraws nothing, and a hand-written reverts line over a commit that reverts nothing (RS001 and RS005) or that takes a different record out is refused." impact: internal resolved_by: commit: "aa9a1c241" diff --git a/.abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md b/.abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md index b79cc1235..62570a3b9 100644 --- a/.abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md +++ b/.abcd/work/issues/resolved/iss-2609281314564762-the-two-ci-gate-scripts-test-membership-and-matches-by.md @@ -9,7 +9,7 @@ found_during: "autonomous run A resumed 2026-09-25 (lane drainScr, full-history origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" -resolution: "Every membership and match test in scripts/check-issue-resolution.sh and scripts/check-attribution.sh reads a here-string instead of a printf pipe, and RS006's frontmatter reader reads to the end instead of exiting early; the cases suites' own helpers take the same change. Proved by two cases, each failing against the previous scripts: a message declaring 1,100 long ids (about 70 KiB) is read whole by RS004 (the previous gate refused 39 of its declared ids), and a Co-authored-by line at the top of a 200 KiB pull-request body is refused (the previous gate accepted it)." +resolution: "Every membership and match test in scripts/check-issue-resolution.sh and scripts/check-attribution.sh reads a here-string instead of a printf pipe, and RS006's frontmatter reader reads to the end instead of exiting early; the cases suites' own helpers take the same change. Proved by two cases, each failing against the previous scripts: a message declaring 1,100 long ids (about 70 KiB) is read whole by RS004 (the previous gate refused 39 of its declared ids), and a Co-authored-by line at the top of a 200 KiB pull-request body is refused (the previous gate accepted it). The RS006 reader half landed in eff2eefda, proved by a resolved record of about 200 KiB passing (the previous gate ended at exit 141)." impact: internal resolved_by: commit: "5b7c79426" From 27c33ada233b61cfb36fe98ee4c378a32ef90743 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:08:13 +0100 Subject: [PATCH 62/78] fix(scripts): a revert withdraws only what the commit it names put in A live revert withdrew every declared id its own diff took back out of a terminal folder, whichever commit had put the record there. A fix could declare Resolves: and move nothing, a separate commit move the record into resolved/, and a third undo that move under a "This reverts commit" line naming the fix: the fix's trailer was withdrawn and it landed with its record open. Delivers: had the same shape. A withdrawal of id N from commit A is now honoured only when A's own diff moved N into the terminal folder the revert's own diff takes it back out of, folder by folder (ids_withdrawn: ids_taken_out of the revert intersected with ids_put_in of the named commit). A git revert of a real resolution or delivery still withdraws. A merge commit is never a named commit here, because the range is read with --no-merges. A root commit is diffed against the empty tree. The shared reader of a commit's terminal-folder moves also ends its loop on an if, not a trailing test. A revert whose diff listed a non-record file (a .gitkeep) last had ended the gate at exit 2 with no refusal. AGENTS.md's RS001 and RS005 bullets state the tighter rule. Refs: iss-2609240646533487 Assisted-by: Claude:claude-opus-5-5 --- AGENTS.md | 7 +- scripts/check-issue-resolution-cases.sh | 74 ++++++++++++++++ scripts/check-issue-resolution.sh | 113 ++++++++++++++++++------ 3 files changed, 162 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 95e972ce5..0fbdfb947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -348,8 +348,9 @@ irreversible; guessing downward costs nothing.** leaves no marker to find it by. Resolving without a trailer stays legal — a stale issue closed on its own merits has no fixing commit to name. A `git revert` later in the same range withdraws a `Resolves:` only for a record its - own diff takes back out of `resolved/` or `wontfix/`; a "This reverts commit" - line over a commit that moves no record withdraws nothing. + own diff takes back out of `resolved/` or `wontfix/`, a record the reverted + commit itself moved in; a "This reverts commit" line over a commit that moves + no record, or naming a commit that never moved that record, withdraws nothing. - **A change that delivers a planned intent closes its spec in the same change**: `go run ./cmd/abcd spec close ` moves the spec to `closed/` and, as its close-hook, the intent from `planned/` to `shipped/`. Nothing @@ -370,7 +371,7 @@ irreversible; guessing downward costs nothing.** refused before anything moves. Same shape as the issue rule above: the step that happens after the merge is the one that gets forgotten. A revert withdraws a `Delivers:` on the same terms, only for an intent its own diff - takes back out of `shipped/`. + takes back out of `shipped/`, an intent the reverted commit itself moved in. - **A `resolved_by.commit` stamp names a commit that is actually reachable.** `abcd capture resolve --commit` is shape-checked only, so a wrong sha reads exactly like a right one; RS002/RS003 check reachability instead. Note the diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 1b6ca0967..1e94fd140 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -1377,6 +1377,80 @@ This reverts commit $(git -C "$d" rev-parse HEAD)." expect_refusal_naming "$d" "RS005 a hand-written revert line over a commit that reverts nothing withdraws nothing" \ "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD +# The deed must also be the REVERTED commit's. A fix declares `Resolves:` and +# moves nothing; a separate commit moves the record into resolved/; a third +# undoes that move under a line naming the fix. The revert's diff does take +# iss-999 out of resolved/, but the commit it names never put it there, so the +# fix's declaration is not withdrawn: the record is open at head and RS001 +# refuses the trailer. +d="$(newrepo rs001-revert-names-another-commit)" +echo "the fix" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +a="$(git -C "$d" rev-parse HEAD)" +resolve_record "$d" +git -C "$d" commit -qm "chore: move the record" +git -C "$d" mv "$ISS_DIR/resolved/iss-999-a-fixture.md" "$ISS_DIR/open/iss-999-a-fixture.md" +git -C "$d" commit -qm "Revert \"fix: something\" + +This reverts commit $a." +expect_refusal_naming "$d" "RS001 a revert naming a commit that never moved the record withdraws nothing" \ + "declares 'Resolves: iss-999', but iss-999 does not enter" -- commits main HEAD + +# The RS005 twin: the delivery declared on a commit that ships nothing, the +# intent shipped by a separate commit, and that shipping undone under a line +# naming the declaring commit. +d="$(newrepo_intents rs005-revert-names-another-commit)" +echo "the thing" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +a="$(git -C "$d" rev-parse HEAD)" +ship_intent "$d" 7 +git -C "$d" commit -qm "chore: close the spec" +git -C "$d" mv "$INT_DIR/shipped/itd-7-fixture-7.md" "$INT_DIR/planned/itd-7-fixture-7.md" +git -C "$d" mv "$SPC_DIR/closed/spc-7-fixture-7.md" "$SPC_DIR/open/spc-7-fixture-7.md" +git -C "$d" commit -qm "Revert \"feat: build the thing\" + +This reverts commit $a." +expect_refusal_naming "$d" "RS005 a revert naming a commit that never shipped the intent withdraws nothing" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD + +# The honest shape still withdraws when the reverted commit is not the tip: a +# resolution, an unrelated commit after it, then `git revert` of the resolution +# itself. The revert takes out of resolved/ exactly what the named commit put in. +d="$(newrepo rs001-revert-of-earlier-resolution)" +resolve_record "$d" +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +a="$(git -C "$d" rev-parse HEAD)" +echo "later work" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "docs: later work" +git -C "$d" revert --no-edit "$a" >/dev/null +expect pass "$d" "RS001 a git revert of an earlier resolution still withdraws it" -- commits main HEAD + +# A file in a terminal folder that is not a record is not a move. Here the +# resolution also adds wontfix/.gitkeep, so its revert's diff lists that file +# last; the reader of the revert's diff once took the loop's last failed test +# for its own status and ended the gate at exit 2 with no refusal line. +d="$(newrepo rs001-revert-lists-a-non-record-last)" +git -C "$d" rm -q "$ISS_DIR/wontfix/.gitkeep" +git -C "$d" commit -qm "chore: drop the placeholder" +resolve_record "$d" +mkdir -p "$d/$ISS_DIR/wontfix" +touch "$d/$ISS_DIR/wontfix/.gitkeep" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +git -C "$d" revert --no-edit HEAD >/dev/null +expect pass "$d" "RS001 a revert whose diff lists a non-record file last still withdraws" -- commits main HEAD + # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary # case (a trailer whose record stays where it was); each refusal is normalised by diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index ee916c732..6785aaadc 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -599,7 +599,7 @@ revert_pairs() { # itself reverted there (a revert of a revert reinstates), and returns 0, or # returns 1 when nothing does. Naming a commit is only the claim; which of its # declarations the revert withdraws is decided by what the revert DID -# (ids_taken_out). +# (ids_withdrawn). withdrawn_by() { local sha="$1" pairs="$2" r found=1 for r in $(printf '%s\n' "$pairs" | awk -v s="$sha" '$2 == s { print $1 }'); do @@ -611,49 +611,102 @@ withdrawn_by() { return "$found" } -# ids_taken_out prints the records commit r's OWN diff takes back out of their -# terminal folder: an iss-N that leaves resolved/ or wontfix/ and enters neither, -# or a canonical itd-N that leaves shipped/ and does not re-enter it. This is the -# deed a withdrawal is judged on. The "This reverts commit" line is text anyone -# can type, and honoured on the text alone a commit that reverts nothing — one -# README line under a hand-written revert line — withdrew a `Resolves:` whose -# record never moved, so a fix without its resolution passed RS001. A `git -# revert` of a real resolution or delivery moves the record back out; a -# hand-written line over a commit that moves nothing takes nothing out, and the -# declaration stands to be judged. A move between terminal folders, or a reslug -# inside one, leaves the record terminal and takes nothing out. Renames are off, -# so every move reads as a delete plus an add and the two halves are paired by id. -ids_taken_out() { - local r="$1" out rc=0 - out="$(git diff-tree --no-commit-id --name-status -r --no-renames "$r^" "$r" -- \ +# terminal_moves prints " " for every record commit c's OWN diff +# deletes from, or adds to, a terminal folder: an iss-N under resolved/ or +# wontfix/, or a canonical itd-N under shipped/. Renames are off, so a move reads +# as a delete plus an add, paired by id below. The one-commit form diffs c +# against its single parent (a root commit against the empty tree); a merge +# commit yields no lines, and none reaches here, since the range is read with +# --no-merges. +terminal_moves() { + local c="$1" out rc=0 + out="$(git diff-tree --root --no-commit-id --name-status -r --no-renames "$c" -- \ "$ISSUES_DIR/resolved" "$ISSUES_DIR/wontfix" "$INTENTS_DIR/shipped" 2>&1)" || rc=$? if [ "$rc" -ne 0 ]; then - echo "check-issue-resolution: git diff-tree failed for ${r:0:12} (exit $rc) — refusing rather than reporting a vacuous pass:" >&2 + echo "check-issue-resolution: git diff-tree failed for ${c:0:12} (exit $rc) — refusing rather than reporting a vacuous pass:" >&2 echo "$out" >&2 exit 2 fi printf '%s\n' "$out" | while IFS=$'\t' read -r status path; do - local id="" + local id="" folder="" case "$status" in D | A) ;; *) continue ;; esac case "$path" in - "$ISSUES_DIR/"*) id="$(basename "$path" | grep -oE '^iss-[0-9]+' || true)" ;; - "$INTENTS_DIR/shipped/"*) id="$(canon_itd "$(basename "$path" | grep -oE '^itd-[0-9]+' || true)")" ;; + "$ISSUES_DIR/resolved/"*) folder=resolved ;; + "$ISSUES_DIR/wontfix/"*) folder=wontfix ;; + "$INTENTS_DIR/shipped/"*) folder=shipped ;; + esac + case "$folder" in + resolved | wontfix) id="$(basename "$path" | grep -oE '^iss-[0-9]+' || true)" ;; + shipped) id="$(canon_itd "$(basename "$path" | grep -oE '^itd-[0-9]+' || true)")" ;; esac - [ -n "$id" ] && printf '%s %s\n' "$status" "$id" - done | awk '$1 == "D" { d[$2] = 1 } $1 == "A" { a[$2] = 1 } END { for (k in d) if (!(k in a)) print k }' | sort -u + # An if, not `[ ] && printf`: a false test as the last command the + # loop runs would become the loop's status, and a non-record file in a + # terminal folder (a .gitkeep) listed last would end the gate at exit 2. + if [ -n "$id" ]; then + printf '%s %s %s\n' "$status" "$folder" "$id" + fi + done +} + +# ids_taken_out prints " " for each record commit r's OWN diff takes +# back out of its terminal folder: it leaves resolved/, wontfix/ or shipped/ and +# enters no terminal folder. This is the deed a withdrawal is judged on. The +# "This reverts commit" line is text anyone can type, and honoured on the text +# alone a commit that reverts nothing — one README line under a hand-written +# revert line — withdrew a `Resolves:` whose record never moved, so a fix +# without its resolution passed RS001. A `git revert` of a real resolution or +# delivery moves the record back out; a hand-written line over a commit that +# moves nothing takes nothing out, and the declaration stands to be judged. A +# move between terminal folders, or a reslug inside one, leaves the record +# terminal and takes nothing out. +ids_taken_out() { + local moves + moves="$(terminal_moves "$1")" || exit 2 + printf '%s\n' "$moves" | + awk 'NF == 3 && $1 == "D" { d[$2 " " $3] = 1 } NF == 3 && $1 == "A" { a[$3] = 1 } + END { for (k in d) { split(k, p, " "); if (!(p[2] in a)) print k } }' | sort -u +} + +# ids_put_in prints " " for each record commit c's OWN diff moves +# INTO a terminal folder it did not already sit in within that commit (a reslug +# inside the folder is not an entry). +ids_put_in() { + local moves + moves="$(terminal_moves "$1")" || exit 2 + printf '%s\n' "$moves" | + awk 'NF == 3 && $1 == "A" { a[$2 " " $3] = 1 } NF == 3 && $1 == "D" { d[$2 " " $3] = 1 } + END { for (k in a) if (!(k in d)) print k }' | sort -u +} + +# ids_withdrawn prints the ids a live revert r withdraws from the commit sha it +# names: those r takes back out of a terminal folder that sha's OWN diff moved +# them into, folder by folder. Taking a record out is not enough on its own: a +# fix that declared `Resolves:` and moved nothing, a separate commit that moved +# the record into resolved/, and a revert of THAT move under a line naming the +# fix withdrew the fix's trailer, and the fix landed with its record open. Only +# a revert of the move the named commit itself made undoes that commit's +# declaration — which is what `git revert ` of a real resolution does. +ids_withdrawn() { + local r="$1" sha="$2" out put + out="$(ids_taken_out "$r")" || exit 2 + [ -n "$out" ] || return 0 + put="$(ids_put_in "$sha")" || exit 2 + [ -n "$put" ] || return 0 + comm -12 <(printf '%s\n' "$out") <(printf '%s\n' "$put") | awk '{ print $2 }' | sort -u } # withdrawn_note reports, and returns 0 for, a declared id that a live revert of -# its commit takes back out of the terminal folder; it returns 1 for any other. +# its commit takes back out of the terminal folder that commit moved it into; it +# returns 1 for any other. withdrawn_note() { local rule="$1" sha="$2" decl="$3" id="$4" withdrawn="$5" w [ -n "$withdrawn" ] || return 1 w="$(awk -v id="$id" '$1 == id && !seen { print $2; seen = 1 }' <<<"$withdrawn")" [ -n "$w" ] || return 1 - echo "check-issue-resolution: $rule commit ${sha:0:12}'s '$decl' is withdrawn: ${w:0:12} reverts it and takes $id back out of its terminal folder" + echo "check-issue-resolution: $rule commit ${sha:0:12}'s '$decl' is withdrawn: ${w:0:12} reverts it and takes $id back out of the terminal folder that commit moved it into" return 0 } @@ -735,9 +788,10 @@ check_commits() { # A declaration a later commit of the range reverts is withdrawn: its # `Resolves:` and `Delivers:` ids are not held to a move the revert undid — - # but only the ids whose record the revert's own diff takes back out of its - # terminal folder (ids_taken_out). RS004 still reads the message — a - # withdrawn commit named what it named. + # but only the ids whose record the revert's own diff takes back out of a + # terminal folder the reverted commit's own diff moved it into + # (ids_withdrawn). RS004 still reads the message — a withdrawn commit named + # what it named. local reverts reverts="$(revert_pairs "$base" "$head" "$range")" @@ -759,12 +813,13 @@ check_commits() { check_mentions "commit ${sha:0:12}" "$msg" "$(declared_ids "$msg")" scanned=$((scanned + 1)) # withdrawn holds " " for each record a live revert of - # this commit takes back out; an id absent from it is judged as usual. + # this commit takes back out of a terminal folder this commit put it in; + # an id absent from it is judged as usual. local withdrawn="" withdrawers w if [ -n "$reverts" ] && withdrawers="$(withdrawn_by "$sha" "$reverts")"; then for w in $withdrawers; do local taken - taken="$(ids_taken_out "$w")" || exit 2 + taken="$(ids_withdrawn "$w" "$sha")" || exit 2 [ -n "$taken" ] || continue withdrawn="$withdrawn$(printf '%s\n' "$taken" | sed "s/\$/ $w/") " From e86e68a60e424c2f6fb15c0b6812feeace842463 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:13:13 +0100 Subject: [PATCH 63/78] =?UTF-8?q?chore:=20capture=20iss-2609281613094952?= =?UTF-8?q?=20=E2=80=94=20the=20status=20board=20prints=20the=20checkout's?= =?UTF-8?q?=20absolute=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: iss-2609281613094952 Assisted-by: Claude:claude-opus-5-5 --- ...us-board-prints-the-checkout-s-absolute-path.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md diff --git a/.abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md b/.abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md new file mode 100644 index 000000000..36778e01d --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609281613094952" +slug: "the-status-board-prints-the-checkout-s-absolute-path" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainRedact" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/core.go" +--- + +The status board prints the checkout's absolute path with no redaction at all: core.Status sets Dir to filepath.Abs(cwd) (internal/core/core.go:46), bare abcd renders it raw as its first line ('abcd — ', internal/surface/cli/cli.go:285) and abcd --json carries it whole as dir. Under HOME it prints /Users//..., and outside HOME the full path, in the output a person pastes most often. The board should name the checkout by the display rule fsutil.DisplayPath states (home-relative under HOME, the directory's base name outside it), the sibling of iss-2609281329007423, which routed every other checkout and worktree display but not this one. From a03e1a9752dd13052b2e152143a22bedcb3b858a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:16:54 +0100 Subject: [PATCH 64/78] fix(cli): the status board names the checkout by the display rule Bare `abcd` printed the checkout's absolute path as its first line and as `dir` in --json, with no redaction at all, so under HOME it named the account and outside HOME the whole local path, in the output a person pastes most often. The board now routes the directory through fsutil.DisplayPath (home-relative under HOME, the base name outside it) in both forms. `dir` is a display field: its readers are the plugin page, which relays it to the person, and two tests that check it is present; no consumer acts on it. The sweep of the board's other lines found one more of the class: the peers notice on stderr carried the reader's error through RedactHome alone, and an unreadable record folder's error names the folder by its absolute path. It is named by DisplayPathsIn against the checkout root. Refs: iss-2609281613094952 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/08-abcd.md | 7 +- README.md | 2 +- commands/abcd.md | 7 +- internal/surface/cli/board_path_test.go | 125 ++++++++++++++++++ internal/surface/cli/cli.go | 7 + internal/surface/cli/peers.go | 5 +- 6 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 internal/surface/cli/board_path_test.go diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index a1d03fb0b..45cc7eeea 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -37,8 +37,11 @@ Two read-only forms, and no third. **Bare `abcd`** renders a four-field snapshot of the current directory: the directory itself, whether it is a git repo, whether an abcd record is present, -and which of the `.abcd/` work tiers exist. The plugin command invokes its JSON -form. +and which of the `.abcd/` work tiers exist. The directory is named home-relative +(`~/…`), or by its directory name outside HOME, in the text form's first line +and in the JSON form's `dir` alike, never by an absolute path +(iss-2609281613094952): the board is the output most often pasted, and no +consumer acts on `dir`. The plugin command invokes its JSON form. **`abcd `** takes a single positional matching `iss-N`, `itd-N`, `spc-N`, `adr-N`, `adm-N`, `srp-N` or `rfm-N` and reports, read-only, what that diff --git a/README.md b/README.md index 70a9fc4cd..32e03a244 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ In a plugin session, inside a repository you own, `/abcd:prepare-this-repo` audi ```text $ abcd -abcd — /path/to/your-repo +abcd — ~/code/your-repo git repo: true record: true work tiers: [development work work.local] diff --git a/commands/abcd.md b/commands/abcd.md index 8788d7413..baeeae7d5 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -15,9 +15,10 @@ Run: "${CLAUDE_PLUGIN_ROOT}/abcd" --json ``` -Then summarise the JSON for the user: the directory, whether it is a git repo, -whether the abcd development record is present, and which `.abcd/` work tiers -exist. +Then summarise the JSON for the user: the directory (`dir`, named +home-relative as `~/…`, or by its directory name outside HOME, never by an +absolute path), whether it is a git repo, whether the abcd development record is +present, and which `.abcd/` work tiers exist. In a repository abcd manages the board also carries one line of presence — the `statusline` object in the JSON (`state`, `plain`, `elements`), rendered as a diff --git a/internal/surface/cli/board_path_test.go b/internal/surface/cli/board_path_test.go new file mode 100644 index 000000000..bb45984ba --- /dev/null +++ b/internal/surface/cli/board_path_test.go @@ -0,0 +1,125 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// boardCheckout makes a git checkout at dir under a fresh HOME and moves the +// test into it, so bare `abcd` renders the board for dir. +func boardCheckout(t *testing.T, home, dir string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("ABCD_PLUGIN_ROOT", "") + t.Setenv("CLAUDE_PLUGIN_ROOT", "") + gitInitAt(t, dir) + t.Chdir(dir) +} + +// boardDir runs the board in both forms and returns the directory the text +// form's first line names and the JSON form's dir, with the whole text output. +func boardDir(t *testing.T) (textDir, jsonDir, text string) { + t.Helper() + text = string(runCLI(t)) + first, _, _ := strings.Cut(text, "\n") + textDir, ok := strings.CutPrefix(first, "abcd — ") + if !ok { + t.Fatalf("the board's first line is not `abcd — `:\n%s", text) + } + var got struct { + Dir string `json:"dir"` + } + if err := json.Unmarshal(runCLI(t, "--json"), &got); err != nil { + t.Fatal(err) + } + return textDir, got.Dir, text +} + +// absSpellings is p in the spelling given and its symlink-resolved one (the +// one os.Getwd and git report back on macOS, where /var is /private/var). +func absSpellings(p string) []string { + out := []string{p} + if r, err := filepath.EvalSymlinks(p); err == nil && r != p { + out = append(out, r) + } + return out +} + +// The board is the output most often pasted, and it named the checkout by its +// absolute path, /Users//… included (iss-2609281613094952). Under +// HOME it names it home-relative, in the text form and in --json. +func TestBoardNamesACheckoutUnderHomeHomeRelative(t *testing.T) { + home := t.TempDir() + boardCheckout(t, home, filepath.Join(home, "code", "the-repo")) + + textDir, jsonDir, text := boardDir(t) + want := "~" + string(filepath.Separator) + filepath.Join("code", "the-repo") + if textDir != want { + t.Errorf("the board's first line names %q, want %q", textDir, want) + } + if jsonDir != want { + t.Errorf("--json dir = %q, want %q", jsonDir, want) + } + noHomePath(t, home, text) + noHomePath(t, home, jsonDir) +} + +// Outside HOME the home redaction leaves the path whole, so the board names +// the checkout by its directory name, fsutil.DisplayPath's rule. +func TestBoardNamesACheckoutOutsideHomeByItsDirectoryName(t *testing.T) { + outside := t.TempDir() + boardCheckout(t, t.TempDir(), filepath.Join(outside, "the-repo")) + + textDir, jsonDir, text := boardDir(t) + if textDir != "the-repo" { + t.Errorf("the board's first line names %q, want the directory name %q", textDir, "the-repo") + } + if jsonDir != "the-repo" { + t.Errorf("--json dir = %q, want the directory name %q", jsonDir, "the-repo") + } + for _, abs := range absSpellings(outside) { + if strings.Contains(text, abs) || strings.Contains(jsonDir, abs) { + t.Errorf("the board prints the absolute checkout path under %s:\n%s\ndir: %s", abs, text, jsonDir) + } + } +} + +// The board's peers notice carried the reader's error through the home +// redaction alone, and an unreadable record folder's error names the folder by +// its absolute path, so a checkout outside HOME was printed whole on stderr. +// The notice names it by the display rule, like the board's first line. +func TestBoardPeersNoticeNamesACheckoutOutsideHomeByItsDirectoryName(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("chmod 0 does not deny root, so the record folder stays readable and no notice is printed") + } + outside := t.TempDir() + repo := filepath.Join(outside, "the-repo") + boardCheckout(t, t.TempDir(), repo) + locked := filepath.Join(repo, ".abcd", "work", "issues", "open") + if err := os.MkdirAll(locked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + + _, stderr, err := runCLISplit(t) + if err != nil { + t.Fatalf("the board failed: %v\n%s", err, stderr) + } + if !strings.Contains(stderr, "the peers line is omitted") { + t.Fatalf("precondition: an unreadable record folder omits the peers line with a notice, got stderr:\n%s", stderr) + } + if !strings.Contains(stderr, filepath.Join("the-repo", ".abcd", "work", "issues", "open")) { + t.Errorf("the notice no longer names the folder it could not read:\n%s", stderr) + } + for _, abs := range absSpellings(outside) { + if strings.Contains(stderr, abs) { + t.Errorf("the board's notice prints the absolute checkout path under %s:\n%s", abs, stderr) + } + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index c08b6ddcd..d558d20ca 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -280,6 +280,13 @@ func NewRootCommand() *cobra.Command { if err != nil { return err } + // The board is the output most often pasted, so it names the + // checkout by the display rule — home-relative under HOME, the + // directory's base name outside it — in the text form and in + // --json alike (iss-2609281613094952). No consumer acts on dir: the + // plugin page relays it, and a reader that needs the path already + // has its own working directory. + st.Dir = fsutil.DisplayPath(st.Dir) board := boardOutput{StatusInfo: st, Statusline: boardPresence(cwd, cmd.ErrOrStderr()), Peers: boardPeers(cwd, cmd.ErrOrStderr()), Inbox: boardInbox(cmd.ErrOrStderr()), Oracle: boardOracle(cwd, cmd.ErrOrStderr()), Reviews: boardReviews(cwd, cmd.ErrOrStderr())} return render(cmd.OutOrStdout(), asJSON, board, func(w io.Writer) { fmt.Fprintf(w, "abcd — %s\n", st.Dir) diff --git a/internal/surface/cli/peers.go b/internal/surface/cli/peers.go index 2305fde77..e9c12ec06 100644 --- a/internal/surface/cli/peers.go +++ b/internal/surface/cli/peers.go @@ -183,7 +183,10 @@ func boardPeers(cwd string, stderr io.Writer) *boardPeersLine { } rep, err := peers.Scan(root) if err != nil { - fmt.Fprintf(stderr, "abcd: the peers line is omitted — %s\n", termsafe.Sanitize(fsutil.RedactHome(err.Error()))) + // The reader's error can name a record folder by its absolute path, + // so the checkout is named by the board's display rule here too + // (iss-2609281613094952). + fmt.Fprintf(stderr, "abcd: the peers line is omitted — %s\n", termsafe.Sanitize(fsutil.DisplayPathsIn(err.Error(), root))) return nil } if rep.IDCount() == 0 { From 196c79c176dffd46666779a5020bec45534f4f84 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:16:55 +0100 Subject: [PATCH 65/78] test: skip the two chmod-0 peer tests as root TestPeersNamesAWorktreeOutsideHomeByItsDirectoryName and TestThePeerRefusalNamesAWorktreeOutsideHomeByItsDirectoryName chmod a record folder to 0 to force a not-read peer; root reads through it, so in a root container the assertion failed on the fixture, not the code. Every other chmod-0 test in the tree already skips or probes for root. Assisted-by: Claude:claude-opus-5-5 --- internal/core/implement/loop/loop_test.go | 3 +++ internal/surface/cli/peers_surface_test.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go index fa0ccf368..e7deccd7d 100644 --- a/internal/core/implement/loop/loop_test.go +++ b/internal/core/implement/loop/loop_test.go @@ -183,6 +183,9 @@ func TestStartRefusesAPeerHoldingTheRecord(t *testing.T) { // reached the refusal as an absolute local path, in its name and inside its // not-read reason (iss-2609281329007423). Both name it by its directory name. func TestThePeerRefusalNamesAWorktreeOutsideHomeByItsDirectoryName(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("chmod 0 on the shut worktree's planned folder does not deny root, so its could-not-be-read holder never forms") + } repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) outside := t.TempDir() held := filepath.Join(outside, "wt-held") diff --git a/internal/surface/cli/peers_surface_test.go b/internal/surface/cli/peers_surface_test.go index e15a3b1fe..bd51cf09b 100644 --- a/internal/surface/cli/peers_surface_test.go +++ b/internal/surface/cli/peers_surface_test.go @@ -285,6 +285,9 @@ func TestTheScanBeforeMutatingConventionNamesThePeerListing(t *testing.T) { // worktree's path and the peer-held refusal alike (iss-2609281329007423). Each // names the worktree by its directory name instead, fsutil.DisplayPath's rule. func TestPeersNamesAWorktreeOutsideHomeByItsDirectoryName(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("chmod 0 on the shut worktree's record folder does not deny root, so its not-read reason never forms") + } home, repo := peerCheckout(t) outside := t.TempDir() live := filepath.Join(outside, "wt-live") From b892f43ab7dec98b0abc2946a90e8707e4b8e8ec Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:17:04 +0100 Subject: [PATCH 66/78] =?UTF-8?q?chore:=20resolve=20iss-2609281613094952?= =?UTF-8?q?=20=E2=80=94=20the=20status=20board=20names=20the=20checkout=20?= =?UTF-8?q?by=20the=20display=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609281613094952 Assisted-by: Claude:claude-opus-5-5 --- ...he-status-board-prints-the-checkout-s-absolute-path.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md (56%) diff --git a/.abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md b/.abcd/work/issues/resolved/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md similarity index 56% rename from .abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md rename to .abcd/work/issues/resolved/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md index 36778e01d..e49410b40 100644 --- a/.abcd/work/issues/open/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md +++ b/.abcd/work/issues/resolved/iss-2609281613094952-the-status-board-prints-the-checkout-s-absolute-path.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainRedact" origin: researcher-authored production_mode: hand-written found_at: "internal/core/core.go" +resolution: "The status board routes the checkout through fsutil.DisplayPath in the text form's first line and in --json's dir (a display field: the plugin page relays it and no consumer acts on it), and the board's peers notice names the checkout through DisplayPathsIn against its root, so a checkout under HOME is shown as ~/rel and one outside HOME by its directory name" +impact: fix +resolved_by: + commit: "a03e1a975" --- The status board prints the checkout's absolute path with no redaction at all: core.Status sets Dir to filepath.Abs(cwd) (internal/core/core.go:46), bare abcd renders it raw as its first line ('abcd — ', internal/surface/cli/cli.go:285) and abcd --json carries it whole as dir. Under HOME it prints /Users//..., and outside HOME the full path, in the output a person pastes most often. The board should name the checkout by the display rule fsutil.DisplayPath states (home-relative under HOME, the directory's base name outside it), the sibling of iss-2609281329007423, which routed every other checkout and worktree display but not this one. + +## Grounds + +- pursued: bare abcd names a checkout under HOME as ~/rel and one outside HOME by its directory name, in text, JSON and the peers notice; TestBoardNamesACheckoutUnderHomeHomeRelative, TestBoardNamesACheckoutOutsideHomeByItsDirectoryName or TestBoardPeersNoticeNamesACheckoutOutsideHomeByItsDirectoryName failing would show it wrong From fae902191687938e01ecfff54d183f69b54906e9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:27:12 +0100 Subject: [PATCH 67/78] chore: capture the exclusion floor's early frontmatter close The reading floor closes its block on any column-0 line opening with three dashes, so `----` or `--- x` ends it before the canonical reader's close and an excluded key between the two reaches the corpus. Found by the review of this branch; captured before the fix. Refs: iss-2609281627055603 Assisted-by: Claude:claude-opus-5-5 --- ...rontmatter-block-before-the-canonical-reader.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md diff --git a/.abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md b/.abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md new file mode 100644 index 000000000..39d788c8f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609281627055603" +slug: "exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainFm" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/reading/project.go" +--- + +The reading corpus's exclusion floor closes its frontmatter block earlier than the canonical reader: blockCloser (internal/core/reading/project.go) closes on any column-0 line opening with three dashes, so a column-0 `----` or `--- x` ends the floor's block while frontmatter.IsDelimiter, frontmatter.Fields and a YAML reader read on to the real `---`. The keys between the two closes are frontmatter to the canonical reader and invisible to excludedKeyInFirstBlock and unresolvableFrontmatterShape, so an excluded key there reaches the corpus under a manifest asserting its refusal. Probe: a block opened by `---`, holding `foo: 1`, then `----`, then `secret: x`, then `---` — floor close is line 2, canonical close line 4, Fields reports secret, and excludedKeyInFirstBlock does not refuse it. The allowlist reason in delimiter_canonical_test.go (the floor 'refuses more, never less') is false for this shape. From 3b6a81ad5d333777645cb9e731bfd6219724da1a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:33:01 +0100 Subject: [PATCH 68/78] fix(reading): the floor's key scan ends no earlier than the canonical close The exclusion floor closed its frontmatter block on any column-0 line opening with three dashes, so a `----` or a `--- x` ended the key and shape scans while frontmatter.Fields read on to the real `---`: an excluded key between the two was frontmatter to the canonical reader, invisible to the floor, and travelled under a manifest asserting its refusal. The key and shape scans now run to blockScanEnd, the later of the floor's own close and frontmatter.Close. The floor's close is kept for where the body begins (the fence mask and the heading scan), so each scan takes the reading that refuses more. The reviewer's suggested closer (IsDelimiter, `--- ` or `...`) was not taken: it still fires on `--- x`, which the canonical reader reads through. The delimiter detector also counts literals led by a byte-order mark, and the floor's allowlist reason states the property that now holds. Every committed markdown file (2748) reads identically through the floor before and after: same block, same redaction, same verdict. Refs: iss-2609281627055603 Assisted-by: Claude:claude-opus-5-5 --- .../frontmatter/delimiter_canonical_test.go | 25 ++++--- internal/core/reading/floor_close_test.go | 65 +++++++++++++++++++ internal/core/reading/project.go | 55 +++++++++++----- 3 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 internal/core/reading/floor_close_test.go diff --git a/internal/core/frontmatter/delimiter_canonical_test.go b/internal/core/frontmatter/delimiter_canonical_test.go index e8dbdbc62..7d8c57c1c 100644 --- a/internal/core/frontmatter/delimiter_canonical_test.go +++ b/internal/core/frontmatter/delimiter_canonical_test.go @@ -45,7 +45,7 @@ var delimiterSites = map[string]delimiterSite{ "internal/core/memory/yaml.go": {3, "the memory store's opener tolerates an indented delimiter (documented at frontmatterOpenIndex and textOpensFrontmatter); joinFileFrontmatter WRITES the block's two delimiters; every close is IsDelimiter"}, "internal/core/memory/writer.go": {3, "a WRITER rebuilding a region for parseFrontmatter, and a byte-0 test that leaves a page with a tolerated preamble alone because rebuilding it would drop the preamble"}, "internal/core/history/store.go": {6, "the transcript store's own record format, written by marshalRecord and read back byte-exact: a record this store did not write is refused, which is the point"}, - "internal/core/reading/project.go": {3, "the reading exclusion floor: it reads a block more broadly than IsDelimiter (any line OPENING with three dashes, and YAML's `...`) so an excluded key is refused in every block a YAML-aware reader could see; the floor refuses more, never less"}, + "internal/core/reading/project.go": {3, "the reading exclusion floor: it OPENS a block on any line beginning with three dashes, more broadly than IsDelimiter, and scans its keys and shapes to the LATER of its own prefix close (or `...`) and frontmatter.Close (blockScanEnd), so every line the canonical reader reads as frontmatter is scanned; its body scans start at the earlier close, so every line a renderer shows is scanned too"}, } // TestNoPrivateDelimiterCompare is the one-canonical-primitive detector for the @@ -60,8 +60,9 @@ var delimiterSites = map[string]delimiterSite{ // // The check reads string LITERALS through go/scanner, so a comment quoting a // delimiter is not a claim; a literal counts when its value opens with `---` or -// carries one at the start of a later line. A delimiter assembled at run time is -// outside its reach, and is left to review. +// carries one at the start of a later line, a byte-order mark ahead of it or +// not. A delimiter assembled at run time is outside its reach, and is left to +// review. func TestNoPrivateDelimiterCompare(t *testing.T) { root := filepath.Join("..", "..", "..") // internal/core/frontmatter -> repository root var offenders []string @@ -117,7 +118,8 @@ func TestNoPrivateDelimiterCompare(t *testing.T) { } // delimiterLiterals counts the Go string literals in src whose value opens with -// `---` or carries `---` at the start of a later line. +// `---` or carries `---` at the start of a later line, either one optionally led +// by a byte-order mark. func delimiterLiterals(src []byte) int { fset := token.NewFileSet() file := fset.AddFile("", fset.Base(), len(src)) @@ -136,7 +138,8 @@ func delimiterLiterals(src []byte) int { if err != nil { continue } - if strings.HasPrefix(v, "---") || strings.Contains(v, "\n---") { + if strings.HasPrefix(v, "---") || strings.Contains(v, "\n---") || + strings.HasPrefix(v, "\ufeff---") || strings.Contains(v, "\n\ufeff---") { n++ } } @@ -144,11 +147,15 @@ func delimiterLiterals(src []byte) int { // TestDelimiterLiteralsReadsLiteralsNotComments pins the counter the detector // stands on: a compare in code counts, a delimiter quoted in a comment does not, -// and a raw string spelling a block counts once. +// a raw string spelling a block counts once, and a delimiter led by a byte-order +// mark counts — a private compare against "\ufeff---" is a second opener rule +// that TrimBOM exists to make unnecessary, and a counter blind to it let one be +// substituted for an allowlisted literal with the pinned count unchanged. func TestDelimiterLiteralsReadsLiteralsNotComments(t *testing.T) { t.Parallel() - src := "package p\n// a comment quoting \"---\" is not a claim\nvar a = x == \"---\"\nvar b = `id: a\n---\n`\nvar c = \"-- \"\n" - if got := delimiterLiterals([]byte(src)); got != 2 { - t.Fatalf("delimiterLiterals = %d, want 2", got) + src := "package p\n// a comment quoting \"---\" is not a claim\nvar a = x == \"---\"\nvar b = `id: a\n---\n`\nvar c = \"-- \"\n" + + "var d = x == \"\\ufeff---\"\nvar e = \"id: a\\n\\ufeff---\"\n" + if got := delimiterLiterals([]byte(src)); got != 4 { + t.Fatalf("delimiterLiterals = %d, want 4", got) } } diff --git a/internal/core/reading/floor_close_test.go b/internal/core/reading/floor_close_test.go new file mode 100644 index 000000000..3c19e639e --- /dev/null +++ b/internal/core/reading/floor_close_test.go @@ -0,0 +1,65 @@ +package reading + +import ( + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/frontmatter" +) + +// TestTheFloorsBlockEndsNoEarlierThanTheCanonicalClose: the exclusion floor +// opens a block more broadly than the canonical reader, and that is safe only +// while its scans also END no earlier than the canonical reader's close +// (iss-2609281627055603). +// +// The floor's closer fires on any column-0 line opening with three dashes, so a +// `----` or a `--- x` ended its block while frontmatter.Fields, which closes on +// IsDelimiter alone, read on to the real `---`. The keys between the two closes +// are frontmatter to the canonical reader and were invisible to the floor, so an +// excluded key there travelled under a manifest asserting its refusal. Each case +// first proves the premise — Fields reads the key — and then that the floor +// refuses it. +func TestTheFloorsBlockEndsNoEarlierThanTheCanonicalClose(t *testing.T) { + const warm = "ABCD-WARM-ORIGIN" + for name, doc := range map[string]string{ + "a four-dash line": "---\nid: spc-1\n----\norigin: " + warm + "\n---\n\n# A record\n", + "a delimiter carrying text": "---\nid: spc-1\n--- x\norigin: " + warm + "\n---\n\n# A record\n", + "a BOM-led opener": "\ufeff---\nid: spc-1\n----\norigin: " + warm + "\n---\n\n# A record\n", + "CRLF line ends": "---\r\nid: spc-1\r\n----\r\norigin: " + warm + "\r\n---\r\n\r\n# A record\r\n", + } { + if _, ok := frontmatter.Fields(strings.Split(doc, "\n"))["origin"]; !ok { + t.Fatalf("%s: the premise does not hold — the canonical reader does not read the key", name) + } + err := refuses(t, "spc-1-a-record.md", doc, refusalKeys, refusalHeadings) + if err == nil { + t.Errorf("%s: an excluded key the canonical reader reads as frontmatter was admitted; "+ + "the floor's block ended before the canonical close", name) + continue + } + for _, want := range []string{"spc-1-a-record.md", `"origin"`} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: the refusal does not name %s: %v", name, want, err) + } + } + } + + // The shape scan reads the same extent. A fence delimiter between the two + // closes sits inside the block to the canonical reader; the fence mask starts + // after the floor's own close, so honouring the mask there would hide the + // key the fence wraps. + fenced := "---\nid: spc-1\n----\n```\norigin: " + warm + "\n```\n---\n\n# A record\n" + if err := refuses(t, "spc-1-a-record.md", fenced, refusalKeys, refusalHeadings); err == nil { + t.Error("a fenced key between the floor's close and the canonical close was admitted") + } + + // What the canonical reader reads as body stays body: a rule under a closed + // block, and a four-dash line in the prose, refuse nothing. + for name, doc := range map[string]string{ + "a rule under the block": "---\nid: spc-1\n---\n\n# A record\n\n----\n\norigin: prose, not a key\n", + "a four-dash line in body": "---\nid: spc-1\n---\n\nProse.\n\n--- x\n\nMore prose.\n", + } { + if err := refuses(t, "spc-1-a-record.md", doc, refusalKeys, refusalHeadings); err != nil { + t.Errorf("%s was refused: %v", name, err) + } + } +} diff --git a/internal/core/reading/project.go b/internal/core/reading/project.go index 718d93814..85eb50114 100644 --- a/internal/core/reading/project.go +++ b/internal/core/reading/project.go @@ -332,9 +332,9 @@ func unfencedBody(lines []string, fenced []bool) string { // Redaction is a positive act over what a parser reported, and three shapes slip // past a parser that reports one value per key and matches a title exactly. A // DUPLICATED key keeps its second copy, because Fields keeps the first -// occurrence and drops the rest silently. A frontmatter block closed with four -// dashes is cut from the body by StripFrontmatter, which closes on a `---` -// PREFIX, while Fields wants the delimiter exactly and so reads no fields at all. +// occurrence and drops the rest silently. A block whose bounds two readers draw +// differently — a four-dash line, a delimiter carrying text, YAML's `...` — +// leaves keys that one reader reads as frontmatter and another as prose. // And a heading spelled in another case is not the title the redactor looked for. // // In each case the field travels and the manifest still asserts it was refused, @@ -692,8 +692,14 @@ func skipBlanks(s string, i int) int { return i } -// blockCloser reports whether a line closes a frontmatter block. YAML closes a -// document with `---` or `...`, and both end the block a key scan is walking. +// blockCloser reports whether a line closes the floor's reading of a frontmatter +// block. YAML closes a document with `---` or `...`, and any line OPENING with +// three dashes is taken as a close, so the floor's close lands at or before every +// reader's. That is the right edge for where the BODY begins — the fence mask and +// the heading scan start there, so they read every line some reader renders — and +// the wrong edge for where the block's KEYS end: a `----` or `--- x` closes here +// and not to frontmatter.IsDelimiter, so the key and shape scans read on to +// blockScanEnd instead (iss-2609281627055603). func blockCloser(line string) bool { // Column 0, not "after trimming". YAML closes a document at the left margin, // and trimming first made an ellipsis or a rule INSIDE a block scalar close @@ -1071,10 +1077,12 @@ func unresolvableFrontmatterShape(lines []string, fenced []bool) (int, string, b if strings.HasPrefix(strings.TrimSpace(lines[close]), "...") { return close + 1, "a frontmatter block closed by `...`", true } - for i := open + 1; i < close; i++ { - if fenced[i] { - continue - } + // The mask is not consulted inside the block. It starts after the floor's + // close, so up to there it marks nothing; past it, up to the canonical + // close, it marks lines the canonical reader reads as frontmatter, and + // honouring it would hide exactly those. + end := blockScanEnd(lines, close) + for i := open + 1; i < end; i++ { trimmed := strings.TrimLeft(lines[i], " \t") switch { // The fence delimiter is first because it is the shape that used to @@ -1323,6 +1331,21 @@ func firstBlockRange(lines []string, fenced []bool) (int, int, bool) { return 0, -1, true } +// blockScanEnd is the line the key and shape scans stop at: the LATER of the +// floor's close and the canonical reader's (frontmatter.Close, which closes on +// IsDelimiter alone). Where both readers open a block, the floor's closer fires +// on a prefix and so never lands after the canonical close; where only the floor +// opens one (a `----` on line 0), Close reports none. Taking the later of the +// two scans every line the canonical reader reads as frontmatter and every line +// only the floor counts as frontmatter — a superset, never less. Where the two +// closes agree, the extent is the floor's own. +func blockScanEnd(lines []string, closed int) int { + if c := frontmatter.Close(lines); c > closed { + return c + } + return closed +} + // excludedKeyInFirstBlock reports an excluded key inside the document's // frontmatter block. // @@ -1336,10 +1359,11 @@ func firstBlockRange(lines []string, fenced []bool) (int, int, bool) { // prose, which travels because inclusion admits it and not because redaction // missed it. // -// The looseness kept is the block's own bounds: any line OPENING with three -// dashes delimits it, not an exact `---`, because that is the rule the -// frontmatter stripper applies and the gap between the two rules is where a key -// survives. +// The scan runs to blockScanEnd, not to the floor's own close: the floor OPENS +// on any line beginning with three dashes, which refuses more, but closing on +// that same prefix ended the scan at a `----` or a `--- x` that the canonical +// reader reads through, and a key below it travelled (iss-2609281627055603). +// The mask is not consulted, for the reason unresolvableFrontmatterShape gives. func excludedKeyInFirstBlock(lines []string, fenced []bool, keys map[string]bool) (int, string, bool) { open, closed, ok := firstBlockRange(lines, fenced) if !ok { @@ -1347,13 +1371,10 @@ func excludedKeyInFirstBlock(lines []string, fenced []bool, keys map[string]bool } end := len(lines) if closed >= 0 { - end = closed + end = blockScanEnd(lines, closed) } depth := 0 for i := open + 1; i < end; i++ { - if fenced[i] { - continue - } // Four spellings, because the field reader reports one of them. A plain // or quoted key at any indent; YAML's explicit-key form; a key inside a // flow mapping at top level or nested; and a double-quoted key whose name From b72f31d3dc67a6c265442805bee0677f732832e7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:33:10 +0100 Subject: [PATCH 69/78] =?UTF-8?q?chore:=20resolve=20iss-2609281627055603?= =?UTF-8?q?=20=E2=80=94=20the=20floor's=20scans=20end=20no=20earlier=20tha?= =?UTF-8?q?n=20the=20canonical=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609281627055603 Assisted-by: Claude:claude-opus-5-5 --- ...s-its-frontmatter-block-before-the-canonical-reader.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md (69%) diff --git a/.abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md b/.abcd/work/issues/resolved/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md similarity index 69% rename from .abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md rename to .abcd/work/issues/resolved/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md index 39d788c8f..7a4ce6e4f 100644 --- a/.abcd/work/issues/open/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md +++ b/.abcd/work/issues/resolved/iss-2609281627055603-exclusion-floor-closes-its-frontmatter-block-before-the-canonical-reader.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainFm" origin: researcher-authored production_mode: hand-written found_at: "internal/core/reading/project.go" +resolution: "The floor's key and shape scans run to blockScanEnd, the later of its own prefix close and frontmatter.Close, so a four-dash line or a delimiter carrying text no longer ends them early; the body scans keep the earlier close." +impact: fix +resolved_by: + commit: "3b6a81ad5" --- The reading corpus's exclusion floor closes its frontmatter block earlier than the canonical reader: blockCloser (internal/core/reading/project.go) closes on any column-0 line opening with three dashes, so a column-0 `----` or `--- x` ends the floor's block while frontmatter.IsDelimiter, frontmatter.Fields and a YAML reader read on to the real `---`. The keys between the two closes are frontmatter to the canonical reader and invisible to excludedKeyInFirstBlock and unresolvableFrontmatterShape, so an excluded key there reaches the corpus under a manifest asserting its refusal. Probe: a block opened by `---`, holding `foo: 1`, then `----`, then `secret: x`, then `---` — floor close is line 2, canonical close line 4, Fields reports secret, and excludedKeyInFirstBlock does not refuse it. The allowlist reason in delimiter_canonical_test.go (the floor 'refuses more, never less') is false for this shape. + +## Grounds + +- pursued: an excluded key between a `----` (or `--- x`, BOM-led or CRLF) and the canonical close is refused, and every committed markdown file reads identically through the floor; a document Fields reads a key from that the floor admits would show it wrong From 8e49fe94a38ed41c20e17e08731aafb85f34715c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:53:19 +0100 Subject: [PATCH 70/78] fix(scripts): a record id is read from a record file only The issue-resolution gate derived a record id from a path's basename alone at every reader that turns a path into an id: the entering readers for resolved/ and wontfix/ and for shipped/, the merge-base and base listings of what is already terminal, and terminal_moves, which decides what a revert withdraws. So a nested file (resolved/x/.md) or a non-markdown one (resolved/.txt) under a terminal folder counted as that record entering or leaving it: adding one satisfied a Resolves: or Delivers: trailer, reverting it withdrew the trailer, and one sitting in the folder at the fork made an honest resolution read as already terminal. record_path alone required the record shape. record_files is now the one reading of which paths are records: a file directly in a folder of its store, named .md or -.md, the shape record_path and intent_path look an id up by. Every such reader goes through it (record_id for a single path), for iss and itd alike. Twelve cases pin it, each watched fail against the previous gate: the nested and non-.md add refused as not entering, for both rules; a revert of odd files withdrawing nothing; and an odd file at the fork or base not making the real record terminal. Refs: iss-2609240646533487 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 112 ++++++++++++++++++++++++ scripts/check-issue-resolution.sh | 62 ++++++++++--- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 1e94fd140..3fce43141 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -1451,6 +1451,118 @@ Resolves: iss-999" git -C "$d" revert --no-edit HEAD >/dev/null expect pass "$d" "RS001 a revert whose diff lists a non-record file last still withdraws" -- commits main HEAD +# A record file sits directly in its status folder and is named .md or +# -.md. A file under a terminal folder that has an id-shaped name but +# not that shape — nested one directory down, or not markdown — names no record, +# so it neither enters the folder nor, reverted, leaves it. When the id was read +# from the basename alone, adding such a file satisfied a trailer, and a +# `git revert` of the commit that added it withdrew the trailer. +d="$(newrepo rs001-nested-file-enters-nothing)" +mkdir -p "$d/$ISS_DIR/resolved/x" +printf -- '---\nschema_version: 1\nid: "iss-1"\n---\nNested.\n' >"$d/$ISS_DIR/resolved/x/iss-1.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-1" +expect_refusal_naming "$d" "RS001 a nested file under resolved/ is not a record entering it" \ + "declares 'Resolves: iss-1', but iss-1 does not enter" -- commits main HEAD + +d="$(newrepo rs001-non-md-file-enters-nothing)" +echo "not a record" >"$d/$ISS_DIR/resolved/iss-4242.txt" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-4242" +expect_refusal_naming "$d" "RS001 a non-.md file under resolved/ is not a record entering it" \ + "declares 'Resolves: iss-4242', but iss-4242 has no record" -- commits main HEAD + +# The revert half: odd files added under resolved/ and declared, then the commit +# reverted. Nothing was put in, so nothing is withdrawn, and each trailer is +# judged — and refused — as not entering. +d="$(newrepo rs001-odd-files-revert)" +mkdir -p "$d/$ISS_DIR/resolved/x" +echo "not a record" >"$d/$ISS_DIR/resolved/README.md" +printf -- '---\nschema_version: 1\nid: "iss-1"\n---\nNested.\n' >"$d/$ISS_DIR/resolved/x/iss-1.md" +echo "not a record" >"$d/$ISS_DIR/resolved/iss-4242.txt" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-1, iss-4242" +git -C "$d" revert --no-edit HEAD >/dev/null +expect_refusal_naming "$d" "RS001 reverting a nested file withdraws nothing for its id" \ + "declares 'Resolves: iss-1', but iss-1 has no record" -- commits main HEAD +expect_refusal_naming "$d" "RS001 reverting a non-.md file withdraws nothing for its id" \ + "declares 'Resolves: iss-4242', but iss-4242 has no record" -- commits main HEAD +expect_refusal_not_naming "$d" "RS001 reverting odd files reports no withdrawal" \ + "is withdrawn" -- commits main HEAD + +# The merge-base half: a nested id-shaped file already under resolved/ at the +# fork is not the record sitting there, so the honest move of the real record +# out of open/ still enters. +d="$(newrepo rs001-nested-file-at-base-is-not-terminal)" +git -C "$d" checkout -q main +mkdir -p "$d/$ISS_DIR/resolved/x" +printf -- '---\nschema_version: 1\nid: "iss-999"\n---\nNested.\n' >"$d/$ISS_DIR/resolved/x/iss-999.md" +git -C "$d" add -A +git -C "$d" commit -qm "chore: a nested file" +git -C "$d" checkout -q -B work main +resolve_record "$d" +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +expect pass "$d" "RS001 a nested file under resolved/ at the fork does not make the record terminal" -- commits main HEAD + +# The RS005 twins: under shipped/, a nested or non-.md file names no intent, so +# it neither ships one nor, reverted, withdraws a delivery. +d="$(newrepo_intents rs005-nested-file-enters-nothing)" +mkdir -p "$d/$INT_DIR/shipped/x" +cat "$d/$INT_DIR/planned/itd-7-fixture-7.md" >"$d/$INT_DIR/shipped/x/itd-7-fixture-7.md" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +expect_refusal_naming "$d" "RS005 a nested file under shipped/ is not an intent entering it" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD + +d="$(newrepo_intents rs005-non-md-file-enters-nothing)" +echo "not a record" >"$d/$INT_DIR/shipped/itd-7.txt" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +expect_refusal_naming "$d" "RS005 a non-.md file under shipped/ is not an intent entering it" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD + +d="$(newrepo_intents rs005-odd-files-revert)" +mkdir -p "$d/$INT_DIR/shipped/x" +cat "$d/$INT_DIR/planned/itd-7-fixture-7.md" >"$d/$INT_DIR/shipped/x/itd-7-fixture-7.md" +echo "not a record" >"$d/$INT_DIR/shipped/itd-8.txt" +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the things + +Delivers: itd-7, itd-8" +git -C "$d" revert --no-edit HEAD >/dev/null +expect_refusal_naming "$d" "RS005 reverting a nested file withdraws nothing for its id" \ + "declares 'Delivers: itd-7', but itd-7 does not enter" -- commits main HEAD +expect_refusal_naming "$d" "RS005 reverting a non-.md file withdraws nothing for its id" \ + "declares 'Delivers: itd-8', but itd-8 does not enter" -- commits main HEAD +expect_refusal_not_naming "$d" "RS005 reverting odd files reports no withdrawal" \ + "is withdrawn" -- commits main HEAD + +# The base half: a non-.md id-shaped file already under shipped/ at the base is +# not the intent sitting there, so the honest close still ships it. +d="$(newrepo_intents rs005-non-md-file-at-base-is-not-shipped)" +git -C "$d" checkout -q main +echo "not a record" >"$d/$INT_DIR/shipped/itd-7.txt" +git -C "$d" add -A +git -C "$d" commit -qm "chore: a stray file" +git -C "$d" checkout -q -B work main +ship_intent "$d" 7 +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +expect pass "$d" "RS005 a non-.md file under shipped/ at the base does not make the intent shipped" -- commits main HEAD + # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary # case (a trailer whose record stays where it was); each refusal is normalised by diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 6785aaadc..080e8cd51 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -243,6 +243,45 @@ usage() { exit 2 } +# record_files is the one reading of which paths are records. It reads +# repository paths on stdin and prints " " for each that is a record +# file of kind $1 (iss or itd): a file sitting DIRECTLY in a folder of its store +# ($ISSUES_DIR or $INTENTS_DIR) and named .md or -.md, the shape +# record_path and intent_path look an id up by. The caller scopes the folders; +# the id is printed as the filename spells it, and an itd id is canonicalised by +# the caller through canon_itd. Anything else in a status folder names no +# record — a nested file (resolved/x/iss-1.md), a non-.md file +# (resolved/iss-4242.txt), a README — so it neither enters a folder nor, +# reverted, leaves one. Read from the basename alone, such a file satisfied a +# trailer by being added, and withdrew it by being reverted (iss-2609240646533487). +record_files() { + local kind="$1" root + case "$kind" in + iss) root="$ISSUES_DIR" ;; + itd) root="$INTENTS_DIR" ;; + *) + echo "check-issue-resolution: record_files: unknown record kind '$kind'" >&2 + exit 2 + ;; + esac + awk -v root="$root/" -v kind="$kind" ' + BEGIN { shape = "^[^/]+/" kind "-[0-9]+(-[^/]*)?\\.md$" } + index($0, root) == 1 { + rest = substr($0, length(root) + 1) + if (rest !~ shape) next + slash = index(rest, "/") + name = substr(rest, slash + 1) + match(name, "^" kind "-[0-9]+") + print substr(name, RSTART, RLENGTH) " " substr(rest, 1, slash - 1) + }' +} + +# record_id prints the id of the record file at path $2 of kind $1, or nothing +# when the path is not a record file (record_files). +record_id() { + printf '%s\n' "$2" | record_files "$1" | cut -d' ' -f1 +} + # ids_entering_closed prints every iss-N whose record ENTERS resolved/ or wontfix/ # across the range — the destination half of a resolution. A record moved from # open/ shows as a rename (or, without rename detection, as an add into the @@ -287,7 +326,7 @@ ids_entering_closed() { esac case "$landed" in "$ISSUES_DIR/resolved/"* | "$ISSUES_DIR/wontfix/"*) - id="$(basename "$landed" | grep -oE '^iss-[0-9]+' || true)" + id="$(record_id iss "$landed")" [ -n "$id" ] || continue grep -qx "$id $(status_of "$landed")" <<<"$terminal_at_mb" && continue printf '%s\n' "$id" @@ -307,16 +346,15 @@ terminal_folders() { echo "$listing" >&2 exit 2 fi - printf '%s\n' "$listing" | sed "s|^$ISSUES_DIR/||" | - awk -F/ 'NF >= 2 && match($NF, /^iss-[0-9]+/) { print substr($NF, RSTART, RLENGTH) " " $1 }' | sort -u + printf '%s\n' "$listing" | record_files iss | sort -u } -# terminal_ids prints, one per line, the id (matched by the ERE in $4, anchored -# at the basename's start) of every record ref holds under the terminal folders -# $2 and $3 ($3 may be empty). The listing is rc-checked: a git failure must not +# terminal_ids prints, one per line, the id of every record of kind $4 +# (record_files) ref holds under the terminal folders $2 and $3 ($3 may be +# empty). The listing is rc-checked: a git failure must not # read as "nothing was terminal", which would re-open the hole this closes. terminal_ids() { - local ref="$1" d1="$2" d2="$3" idre="$4" listing rc=0 + local ref="$1" d1="$2" d2="$3" kind="$4" listing rc=0 if [ -n "$d2" ]; then listing="$(git ls-tree -r --name-only "$ref" -- "$d1" "$d2" 2>&1)" || rc=$? else @@ -327,7 +365,7 @@ terminal_ids() { echo "$listing" >&2 exit 2 fi - printf '%s\n' "$listing" | sed 's|.*/||' | { grep -oE "^$idre" || true; } | sort -u + printf '%s\n' "$listing" | record_files "$kind" | cut -d' ' -f1 | sort -u } # record_path prints the ledger path of iss-N's record at ref — its status @@ -427,7 +465,7 @@ canon_itd() { ids_entering_shipped() { local base="$1" head="$2" shipped_at_base # `|| exit 2` for the reason ids_entering_closed gives. - shipped_at_base="$(terminal_ids "$base" "$INTENTS_DIR/shipped" "" 'itd-[0-9]+')" || exit 2 + shipped_at_base="$(terminal_ids "$base" "$INTENTS_DIR/shipped" "" itd)" || exit 2 shipped_at_base="$(printf '%s\n' "$shipped_at_base" | while IFS= read -r raw; do canon_itd "$raw"; done)" git diff --name-status --find-renames "$base".."$head" -- "${INTENT_PATHSPECS[@]}" | while IFS=$'\t' read -r status path dest; do @@ -438,7 +476,7 @@ ids_entering_shipped() { esac case "$landed" in "$INTENTS_DIR/shipped/"*) - id="$(canon_itd "$(basename "$landed" | grep -oE '^itd-[0-9]+' || true)")" + id="$(canon_itd "$(record_id itd "$landed")")" [ -n "$id" ] || continue grep -qx "$id" <<<"$shipped_at_base" && continue printf '%s\n' "$id" @@ -639,8 +677,8 @@ terminal_moves() { "$INTENTS_DIR/shipped/"*) folder=shipped ;; esac case "$folder" in - resolved | wontfix) id="$(basename "$path" | grep -oE '^iss-[0-9]+' || true)" ;; - shipped) id="$(canon_itd "$(basename "$path" | grep -oE '^itd-[0-9]+' || true)")" ;; + resolved | wontfix) id="$(record_id iss "$path")" ;; + shipped) id="$(canon_itd "$(record_id itd "$path")")" ;; esac # An if, not `[ ] && printf`: a false test as the last command the # loop runs would become the loop's status, and a non-record file in a From 7cb2fd170073679b0e58f7cc43cf6d47250ea4ab Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:36:51 +0100 Subject: [PATCH 71/78] =?UTF-8?q?chore:=20capture=20iss-2609281736483740?= =?UTF-8?q?=20=E2=80=94=20the=20status=20board's=20first=20line=20is=20not?= =?UTF-8?q?=20sanitised?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: iss-2609281736483740 Assisted-by: Claude:claude-opus-5-5 --- ...d-s-first-line-prints-the-checkout-s-display.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md diff --git a/.abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md b/.abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md new file mode 100644 index 000000000..019a1cb85 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609281736483740" +slug: "the-status-board-s-first-line-prints-the-checkout-s-display" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: fix2-drainRedact" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/cli.go" +--- + +The status board's first line prints the checkout's display name without termsafe.Sanitize, while every other board value is sanitised. Bare abcd writes 'abcd — ' from fsutil.DisplayPath(st.Dir) straight to the terminal, so a checkout whose directory name carries an ESC sequence or a bidi control (U+202E) reaches the terminal raw: a crafted clone directory can recolour, retitle or reorder the board a person reads and pastes. Named FOR THE REVIEWER by fix2-drainRedact after it routed the line through DisplayPath. From 7004cc395327ef18645d13111bd737e5bcffcac3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:37:40 +0100 Subject: [PATCH 72/78] fix(cli): the status board's first line is sanitised like every other The board wrote the checkout's display name raw, so a directory name carrying an ESC sequence or a bidi override reached the terminal. The text line now goes through termsafe.Sanitize. The --json dir is left as the true name: encoding/json escapes a control byte, a machine reader needs the real name rather than a masked one, and the board's other JSON fields are likewise unsanitised data for the reader to render. TestBoardFirstLineMasksControlsInTheCheckoutName watched fail (ESC and U+202E directory names printed raw) before the change, pass after. Refs: iss-2609281736483740 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/08-abcd.md | 5 ++- internal/surface/cli/board_path_test.go | 34 +++++++++++++++++++ internal/surface/cli/cli.go | 6 +++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 45cc7eeea..7a144d59d 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -41,7 +41,10 @@ and which of the `.abcd/` work tiers exist. The directory is named home-relative (`~/…`), or by its directory name outside HOME, in the text form's first line and in the JSON form's `dir` alike, never by an absolute path (iss-2609281613094952): the board is the output most often pasted, and no -consumer acts on `dir`. The plugin command invokes its JSON form. +consumer acts on `dir`. The text line masks a control character or bidi control +in that name, as every other board line does (iss-2609281736483740); `dir` +carries the name as it is, escaped by the JSON encoder where it is a control +byte. The plugin command invokes its JSON form. **`abcd `** takes a single positional matching `iss-N`, `itd-N`, `spc-N`, `adr-N`, `adm-N`, `srp-N` or `rfm-N` and reports, read-only, what that diff --git a/internal/surface/cli/board_path_test.go b/internal/surface/cli/board_path_test.go index bb45984ba..52c1cb9c0 100644 --- a/internal/surface/cli/board_path_test.go +++ b/internal/surface/cli/board_path_test.go @@ -123,3 +123,37 @@ func TestBoardPeersNoticeNamesACheckoutOutsideHomeByItsDirectoryName(t *testing. } } } + +// The board's first line is the one board value that reached the terminal +// unsanitised: every other line goes through termsafe.Sanitize, but the +// checkout's display name was written raw, so a directory whose name carries an +// ESC sequence or a bidi override recoloured or reordered the board a person +// reads and pastes (iss-2609281736483740). The text line masks each control; +// --json carries the directory's true name, which the encoder escapes where it +// is a control byte and a reader renders on its own terms, as it does every +// other board field. +func TestBoardFirstLineMasksControlsInTheCheckoutName(t *testing.T) { + for _, tc := range []struct{ label, name, masked, raw string }{ + {"an ESC sequence", "the\x1b[31mrepo", "the?[31mrepo", "\x1b"}, + {"a right-to-left override", "the\u202erepo", "the?repo", "\u202e"}, + } { + t.Run(tc.label, func(t *testing.T) { + outside := t.TempDir() + boardCheckout(t, t.TempDir(), filepath.Join(outside, tc.name)) + + textDir, jsonDir, text := boardDir(t) + if textDir != tc.masked { + t.Errorf("the board's first line names %q, want the masked %q", textDir, tc.masked) + } + if strings.Contains(text, tc.raw) { + t.Errorf("the board's text carries the raw control %q:\n%q", tc.raw, text) + } + if jsonDir != tc.name { + t.Errorf("--json dir = %q, want the directory's true name %q", jsonDir, tc.name) + } + if raw := runCLI(t, "--json"); strings.Contains(string(raw), "\x1b") { + t.Errorf("--json carries a raw ESC byte; the encoder escapes it:\n%q", raw) + } + }) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 6a266da89..2b228d105 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -289,7 +289,11 @@ func NewRootCommand() *cobra.Command { st.Dir = fsutil.DisplayPath(st.Dir) board := boardOutput{StatusInfo: st, Statusline: boardPresence(cwd, cmd.ErrOrStderr()), Peers: boardPeers(cwd, cmd.ErrOrStderr()), Inbox: boardInbox(cmd.ErrOrStderr()), Oracle: boardOracle(cwd, cmd.ErrOrStderr()), Reviews: boardReviews(cwd, cmd.ErrOrStderr())} return render(cmd.OutOrStdout(), asJSON, board, func(w io.Writer) { - fmt.Fprintf(w, "abcd — %s\n", st.Dir) + // Sanitised like every other board line: the directory name is the + // checkout's own, and a name carrying an ESC sequence or a bidi + // control must not reach the terminal raw (iss-2609281736483740). + // --json keeps the true name; the encoder escapes a control byte. + fmt.Fprintf(w, "abcd — %s\n", termsafe.Sanitize(st.Dir)) fmt.Fprintf(w, " git repo: %v\n", st.IsGitRepo) fmt.Fprintf(w, " record: %v\n", st.HasRecord) fmt.Fprintf(w, " work tiers: %v\n", st.WorkTiers) From 331e3f8fd85df9a4c4a5c02742651dc937c23f42 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:37:50 +0100 Subject: [PATCH 73/78] =?UTF-8?q?chore:=20resolve=20iss-2609281736483740?= =?UTF-8?q?=20=E2=80=94=20the=20board's=20first=20line=20masks=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609281736483740 Assisted-by: Claude:claude-opus-5-5 --- ...us-board-s-first-line-prints-the-checkout-s-display.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md (58%) diff --git a/.abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md b/.abcd/work/issues/resolved/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md similarity index 58% rename from .abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md rename to .abcd/work/issues/resolved/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md index 019a1cb85..e0aa887f9 100644 --- a/.abcd/work/issues/open/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md +++ b/.abcd/work/issues/resolved/iss-2609281736483740-the-status-board-s-first-line-prints-the-checkout-s-display.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: fix2-drainRedact" origin: researcher-authored production_mode: hand-written found_at: "internal/surface/cli/cli.go" +resolution: "The board's text first line goes through termsafe.Sanitize, so an ESC sequence or a bidi control in the checkout's directory name is masked; --json dir keeps the true name, which encoding/json escapes where it is a control byte. TestBoardFirstLineMasksControlsInTheCheckoutName was watched fail with both names printed raw, and passes after." +impact: fix +resolved_by: + commit: "7004cc395" --- The status board's first line prints the checkout's display name without termsafe.Sanitize, while every other board value is sanitised. Bare abcd writes 'abcd — ' from fsutil.DisplayPath(st.Dir) straight to the terminal, so a checkout whose directory name carries an ESC sequence or a bidi control (U+202E) reaches the terminal raw: a crafted clone directory can recolour, retitle or reorder the board a person reads and pastes. Named FOR THE REVIEWER by fix2-drainRedact after it routed the line through DisplayPath. + +## Grounds + +- pursued: a checkout directory named with ESC or U+202E prints masked on the board's first line; the raw control reaching the text output, or --json dir no longer carrying the true name, would show it wrong. From 3693fd61e3758f89aa75bd20923fcdcb3f26dafa Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:39:34 +0100 Subject: [PATCH 74/78] docs(issues): a persona home in two records' Windows-path examples The two resolved records quoted a generic login as C:\\Users\\LOGIN, which the privacy backstop (widened by drainLint to the escaped spellings) reads as an absolute local path. The examples now use the persona home carol; record text only, and abcd lint's four privacy lines for them are gone (review-drainLint LOW). Refs: iss-2609251543293588, iss-2609251638574543 Assisted-by: Claude:claude-opus-5-5 --- ...eneric-account-floor-misses-the-json-serialised-windows.md | 4 ++-- ...eneric-account-floor-misses-a-windows-home-root-escaped.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.abcd/work/issues/resolved/iss-2609251543293588-the-generic-account-floor-misses-the-json-serialised-windows.md b/.abcd/work/issues/resolved/iss-2609251543293588-the-generic-account-floor-misses-the-json-serialised-windows.md index 57c554e95..c0ec9f216 100644 --- a/.abcd/work/issues/resolved/iss-2609251543293588-the-generic-account-floor-misses-the-json-serialised-windows.md +++ b/.abcd/work/issues/resolved/iss-2609251543293588-the-generic-account-floor-misses-the-json-serialised-windows.md @@ -15,8 +15,8 @@ resolved_by: commit: "0d213f8f" --- -The generic-account floor misses the JSON-serialised Windows home root. accountRootPrefixes (internal/adapter/scanner/identity.go) carries the single-backslash spelling (\users\) but not the doubled one every JSON encoder writes, so C:\\Users\\LOGIN\\Desktop in a transcript line raises no finding at all while C:\Users\LOGIN\Desktop hard-fails; the home-literal clause of standsAsAccountName compares the configured home verbatim and misses its doubled spelling the same way. Before the generic floor the bare word was flagged, so the floor narrowed a hard_fail rule in the redactor's own input shape: a caller whose login is on the generic list leaks the home path of every Windows path a JSON transcript quotes through capture and history. +The generic-account floor misses the JSON-serialised Windows home root. accountRootPrefixes (internal/adapter/scanner/identity.go) carries the single-backslash spelling (\users\) but not the doubled one every JSON encoder writes, so C:\\Users\\carol\\Desktop in a transcript line raises no finding at all while C:\Users\carol\Desktop hard-fails; the home-literal clause of standsAsAccountName compares the configured home verbatim and misses its doubled spelling the same way. Before the generic floor the bare word was flagged, so the floor narrowed a hard_fail rule in the redactor's own input shape: a caller whose login is on the generic list leaks the home path of every Windows path a JSON transcript quotes through capture and history. ## Grounds -- pursued: a generic login in a JSON-escaped Windows home path is reported as local_username; a transcript line quoting C:\\Users\\LOGIN that yields no local_username finding would show it wrong. +- pursued: a generic login in a JSON-escaped Windows home path is reported as local_username; a transcript line quoting C:\\Users\\carol that yields no local_username finding would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609251638574543-the-generic-account-floor-misses-a-windows-home-root-escaped.md b/.abcd/work/issues/resolved/iss-2609251638574543-the-generic-account-floor-misses-a-windows-home-root-escaped.md index 12f8097d5..4082e4e60 100644 --- a/.abcd/work/issues/resolved/iss-2609251638574543-the-generic-account-floor-misses-a-windows-home-root-escaped.md +++ b/.abcd/work/issues/resolved/iss-2609251638574543-the-generic-account-floor-misses-a-windows-home-root-escaped.md @@ -15,8 +15,8 @@ resolved_by: commit: "a0c126b1" --- -The generic-account floor misses a Windows home root escaped more than once. accountRootPrefixes (internal/adapter/scanner/identity.go) lists the single and the doubled backslash spellings of \users\ and the home-literal clause of standsAsAccountName lists the home and its doubled spelling, so C:\\\\Users\\\\LOGIN\\\\Desktop, the shape a transcript line carries when a tool result is itself JSON text (go env -json, npm config ls --json, any --json output), raises no finding for a login on the generic list while the doubled spelling hard-fails. Before the generic floor the bare word was flagged, so the floor narrowed a hard_fail rule in the redactor input: capture and history redact nothing on such a line. +The generic-account floor misses a Windows home root escaped more than once. accountRootPrefixes (internal/adapter/scanner/identity.go) lists the single and the doubled backslash spellings of \users\ and the home-literal clause of standsAsAccountName lists the home and its doubled spelling, so C:\\\\Users\\\\carol\\\\Desktop, the shape a transcript line carries when a tool result is itself JSON text (go env -json, npm config ls --json, any --json output), raises no finding for a login on the generic list while the doubled spelling hard-fails. Before the generic floor the bare word was flagged, so the floor narrowed a hard_fail rule in the redactor input: capture and history redact nothing on such a line. ## Grounds -- pursued: a generic login under a Windows home root escaped at any depth up to maxSeparatorRun is reported as local_username; a line carrying C:\Users\LOGIN with its separators quadrupled or more that yields no local_username finding, or a meter fixture of escaped roots whose charge grows faster than the line, would show it wrong. +- pursued: a generic login under a Windows home root escaped at any depth up to maxSeparatorRun is reported as local_username; a line carrying C:\Users\carol with its separators quadrupled or more that yields no local_username finding, or a meter fixture of escaped roots whose charge grows faster than the line, would show it wrong. From dbbc63f3426f8665b917ebfa8767b4b860fec9f6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:39:56 +0100 Subject: [PATCH 75/78] chore(record-lint): harness_leak reads the reviews tree too drainLint armed harness_leak over .abcd/work and exempted the reviews tree, mirroring links_resolve. The exemption was inert (the reviews tree holds no session-URL shape) and a receipt names the commit it read, never a session, so it bought only a blind spot the leak class does not need. Dropped; record-lint stays green (review-drainLint LOW). Assisted-by: Claude:claude-opus-5-5 --- .abcd/record-lint.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index 65f53d8b1..4ea533b36 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -238,10 +238,6 @@ "severity": "blocker", "extra_roots": [ ".abcd/work" - ], - "exempt": [ - ".abcd/work/reviews/*", - ".abcd/work/reviews/*/*" ] }, "directory_coverage": { From c629a04607fb843524d3f39b34a3a2f06c7ee26f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:42:43 +0100 Subject: [PATCH 76/78] fix(scripts): the record lookups read a record file only record_path and intent_path still found a nested /x/.md by id, and open_specs_for derived spc ids from any .md under specs/open/, nested files included, so a lookup could disagree with the derivation fix4-drainScr routed through record_files. All three now go through it: record_files takes a spc kind and a `path` form printing " ", record_path and intent_path compare the id whole (itd zero padding admitted) and read the listing to the end, and open_specs_for reads its ids from the predicate. Cases watched fail against the unchanged gate (4 FAIL, 111 ok) and pass after (115 ok): an intent nested under planned/ is no record, a nested spec under specs/open/ is not an open spec and the refusal names no close for it, and the nested-issue case's diagnosis is now "has no record", as for its non-.md sibling. Refs: iss-2609240646533487 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 34 +++++++++++++++- scripts/check-issue-resolution.sh | 53 +++++++++++++++---------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index 3fce43141..bf470a2a4 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -1464,8 +1464,12 @@ git -C "$d" add -A git -C "$d" commit -qm "fix: something Resolves: iss-1" +# The lookup agrees with the derivation: record_path answers only a record +# file, so the nested file is no record at all, and the diagnosis says so as it +# does for the non-.md file below, rather than reading the nested path's folder +# as the record's status. expect_refusal_naming "$d" "RS001 a nested file under resolved/ is not a record entering it" \ - "declares 'Resolves: iss-1', but iss-1 does not enter" -- commits main HEAD + "declares 'Resolves: iss-1', but iss-1 has no record" -- commits main HEAD d="$(newrepo rs001-non-md-file-enters-nothing)" echo "not a record" >"$d/$ISS_DIR/resolved/iss-4242.txt" @@ -1563,6 +1567,34 @@ git -C "$d" commit -qm "feat: build the thing Delivers: itd-7" expect pass "$d" "RS005 a non-.md file under shipped/ at the base does not make the intent shipped" -- commits main HEAD +# The lookups share the predicate too. intent_path answers only an intent file, +# so a nested planned/x/itd-77.md is no intent and the delivery is told there is +# no record, not diagnosed from the nested path's bucket. +d="$(newrepo_intents rs005-nested-intent-is-no-record)" +mkdir -p "$d/$INT_DIR/planned/x" +intent_fixture "$d" planned/x 77 null +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-77" +expect_refusal_naming "$d" "RS005 a nested file under planned/ is not the intent the lookup finds" \ + "declares 'Delivers: itd-77', but itd-77 has no record" -- commits main HEAD + +# open_specs_for derives spec ids through the same predicate: a nested +# open/x/spc-88.md naming itd-8 is not an open spec, so the refusal does not +# send the reader to close it. +d="$(newrepo_intents rs005-nested-spec-is-not-open)" +mkdir -p "$d/$SPC_DIR/open/x" +spec_fixture "$d" open/x 88 itd-8 +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-8" +expect_refusal_not_naming "$d" "RS005 a nested file under specs/open/ is not an open spec naming the intent" \ + "spc-88" -- commits main HEAD +expect_refusal_naming "$d" "RS005 a nested spec leaves the intent with no spec to close" \ + "itd-8 does not enter .* with no spec to close" -- commits main HEAD + # Criterion 5: the intent rule's refusal has the issue rule's shape and exit # code — compared here, not judged by a reviewer. Both fixtures are the ordinary # case (a trailer whose record stays where it was); each refusal is normalised by diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 080e8cd51..707e505c2 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -245,26 +245,29 @@ usage() { # record_files is the one reading of which paths are records. It reads # repository paths on stdin and prints " " for each that is a record -# file of kind $1 (iss or itd): a file sitting DIRECTLY in a folder of its store -# ($ISSUES_DIR or $INTENTS_DIR) and named .md or -.md, the shape -# record_path and intent_path look an id up by. The caller scopes the folders; -# the id is printed as the filename spells it, and an itd id is canonicalised by -# the caller through canon_itd. Anything else in a status folder names no -# record — a nested file (resolved/x/iss-1.md), a non-.md file +# file of kind $1 (iss, itd or spc): a file sitting DIRECTLY in a folder of its +# store ($ISSUES_DIR, $INTENTS_DIR or $SPECS_DIR) and named .md or +# -.md. With a second argument `path` it prints " " instead, +# the form record_path, intent_path and open_specs_for look an id up by, so a +# lookup and a derivation cannot disagree on what a record is. The caller scopes +# the folders; the id is printed as the filename spells it, and an itd id is +# canonicalised by the caller through canon_itd. Anything else in a status +# folder names no record — a nested file (resolved/x/iss-1.md), a non-.md file # (resolved/iss-4242.txt), a README — so it neither enters a folder nor, # reverted, leaves one. Read from the basename alone, such a file satisfied a # trailer by being added, and withdrew it by being reverted (iss-2609240646533487). record_files() { - local kind="$1" root + local kind="$1" form="${2:-}" root case "$kind" in iss) root="$ISSUES_DIR" ;; itd) root="$INTENTS_DIR" ;; + spc) root="$SPECS_DIR" ;; *) echo "check-issue-resolution: record_files: unknown record kind '$kind'" >&2 exit 2 ;; esac - awk -v root="$root/" -v kind="$kind" ' + awk -v root="$root/" -v kind="$kind" -v form="$form" ' BEGIN { shape = "^[^/]+/" kind "-[0-9]+(-[^/]*)?\\.md$" } index($0, root) == 1 { rest = substr($0, length(root) + 1) @@ -272,7 +275,9 @@ record_files() { slash = index(rest, "/") name = substr(rest, slash + 1) match(name, "^" kind "-[0-9]+") - print substr(name, RSTART, RLENGTH) " " substr(rest, 1, slash - 1) + id = substr(name, RSTART, RLENGTH) + if (form == "path") print id " " $0 + else print id " " substr(rest, 1, slash - 1) }' } @@ -370,11 +375,14 @@ terminal_ids() { # record_path prints the ledger path of iss-N's record at ref — its status # folder is the diagnosis RS001 needs — or nothing when the ref holds none. The -# id is matched as a whole basename prefix, so iss-99 never answers for iss-999. +# id is compared whole, so iss-99 never answers for iss-999, and only a record +# file (record_files) answers: a nested resolved/x/iss-N.md is not the record, +# so it never diagnoses one as terminal. The first match wins, as before, and +# the listing is read to the end so no early exit hands git a SIGPIPE. record_path() { local ref="$1" id="$2" git ls-tree -r --name-only "$ref" -- "${STATUS_PATHSPECS[@]}" 2>/dev/null | - grep -E "/${id}(-[^/]*)?\.md\$" | head -1 || true + record_files iss path | awk -v id="$id" '!found && $1 == id { sub(/^[^ ]+ /, ""); print; found = 1 }' || true } # status_of prints the status folder (open, resolved, wontfix) a ledger path sits in. @@ -486,12 +494,13 @@ ids_entering_shipped() { } # intent_path prints the store path of a canonical itd-N at ref, or nothing. The -# id is matched as a whole basename prefix, zero padding admitted, so itd-7 never -# answers for itd-70. +# id is compared whole, zero padding admitted, so itd-7 never answers for itd-70, +# and only a record file (record_files) answers, as in record_path. intent_path() { local ref="$1" id="$2" git ls-tree -r --name-only "$ref" -- "${INTENT_PATHSPECS[@]}" 2>/dev/null | - grep -E "/itd-0*${id#itd-}(-[^/]*)?\.md\$" | head -1 || true + record_files itd path | awk -v n="${id#itd-}" ' + !found { k = $1; sub(/^itd-0*/, "", k); if (k == n) { sub(/^[^ ]+ /, ""); print; found = 1 } }' || true } # bucket_of prints the lifecycle bucket an intent path sits in. @@ -514,18 +523,18 @@ frontmatter_field() { # `intent:` back-link names the canonical itd-N. The back-link, not the intent's # scalar spec_id, is the source of truth for which specs realise an intent # (adr-2609151513118583): a remainder spec is named by nothing on the intent. -# An open/ holding no spec at all is an answer (none), not an error: grep's -# no-match exit (1) is accepted, or pipefail would carry it out through the -# caller's assignment and errexit would end the run with no message. Only that -# status: a git failure, or grep's own (2), still fails the pipeline, because a +# Only a spec file (record_files) counts: a nested open/x/spc-N.md is not an +# open spec, so it never tells a delivery to close one. An open/ holding no +# spec at all is an answer (none), not an error — record_files prints nothing +# and exits 0 — while a git failure still fails the pipeline, because a # swallowed git error reads exactly like an empty store. open_specs_for() { - local ref="$1" id="$2" f back - git ls-tree -r --name-only "$ref" -- "$SPECS_DIR/open" 2>/dev/null | { grep -E '\.md$' || [ "$?" -eq 1 ]; } | - while IFS= read -r f; do + local ref="$1" id="$2" spc f back + git ls-tree -r --name-only "$ref" -- "$SPECS_DIR/open" 2>/dev/null | record_files spc path | + while IFS=' ' read -r spc f; do back="$(frontmatter_field "$ref" "$f" intent)" [ "$(canon_itd "$back")" = "$id" ] || continue - basename "$f" | grep -oE '^spc-[0-9]+' || true + printf '%s\n' "$spc" done } From dace244367cc2af02942107dbe0284e48b34972a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:00:29 +0100 Subject: [PATCH 77/78] chore: recalibrate the reading windows at the integration tip Measured on a clean clone of c629a0460 by dry-run assemble. Widening measures 1,294,728 tokens (4,984,706 bytes), 0.41% under its 1,300,000 window, so the window moves to 1,310,000; detection measures 1,303,764 (5,019,494), 0.48% under 1,310,000, so it moves to 1,320,000; entailment measures 382,666 (1,473,266) and keeps 390,000 with 1.92% headroom. The growth is the drained lanes' capture, intent and lint sources and tests, which the widening and detection objects read. The comparative position is not measured this way, as its preset comment states. 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 5371f647e..cafc1721c 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1300000, - "measured_tokens_est": 1280867, - "measured_bytes": 4931339, - "measured_at": "35483ff77ca726d81633625eaa816cae14b38d73" + "tokens_est": 1310000, + "measured_tokens_est": 1294728, + "measured_bytes": 4984706, + "measured_at": "c629a04607fb843524d3f39b34a3a2f06c7ee26f" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 390000, - "measured_tokens_est": 382072, - "measured_bytes": 1470980, - "measured_at": "35483ff77ca726d81633625eaa816cae14b38d73" + "measured_tokens_est": 382666, + "measured_bytes": 1473266, + "measured_at": "c629a04607fb843524d3f39b34a3a2f06c7ee26f" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1310000, - "measured_tokens_est": 1289903, - "measured_bytes": 4966127, - "measured_at": "35483ff77ca726d81633625eaa816cae14b38d73" + "tokens_est": 1320000, + "measured_tokens_est": 1303764, + "measured_bytes": 5019494, + "measured_at": "c629a04607fb843524d3f39b34a3a2f06c7ee26f" } } } From fb18ac2d59029c447270a90d39a1873c3394f190 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:56:45 +0100 Subject: [PATCH 78/78] chore: recalibrate the reading windows at the integration tip Measured on a clean clone of db30f1a10 (dry-run assemble): widening 1,344,909 tokens / 5,177,902 bytes, 1,350,000 -> 1,360,000; entailment 387,939 / 1,493,566 keeps 400,000 (3.11% headroom); detection 1,353,945 / 5,212,690, 1,360,000 -> 1,370,000. Each window is ceil(t*1.01/10000)*10000. 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 10aa9d299..7047d76da 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1350000, - "measured_tokens_est": 1332620, - "measured_bytes": 5130589, - "measured_at": "c72b6658f76425e3a5265054190527f5a411c17d" + "tokens_est": 1360000, + "measured_tokens_est": 1344909, + "measured_bytes": 5177902, + "measured_at": "db30f1a10992df69d1253260a88065884a21937d" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 400000, - "measured_tokens_est": 387345, - "measured_bytes": 1491280, - "measured_at": "c72b6658f76425e3a5265054190527f5a411c17d" + "measured_tokens_est": 387939, + "measured_bytes": 1493566, + "measured_at": "db30f1a10992df69d1253260a88065884a21937d" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1360000, - "measured_tokens_est": 1341656, - "measured_bytes": 5165377, - "measured_at": "c72b6658f76425e3a5265054190527f5a411c17d" + "tokens_est": 1370000, + "measured_tokens_est": 1353945, + "measured_bytes": 5212690, + "measured_at": "db30f1a10992df69d1253260a88065884a21937d" } } }