From b2fe9f2736eda2a2df5366bfd4ef268ddbe06679 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:44:00 +0100
Subject: [PATCH 01/84] fix(site): render a three-backtick fence indented one
to three spaces
A three-backtick opener indented one to three spaces is a fence by
CommonMark and by mdrecord's walk, but the renderer rendered one only at
the left margin or inside a list item it dedents. Anywhere else the block
became a paragraph holding inline code, with no error. The shape is live
in the record: the fence of a loose list item arrives as its own block,
cut from the item by the blank line above it (itd-5, itd-1, a closed
spec, research notes).
The block now renders as a fence, each line losing up to the opener's
indent, and any lines after an indented closer render as the block they
are. The tilde and four-backtick twins stay refused, and an indented
opener under a line of prose is refused as a fence without a blank line
before it, as the margin one is.
Refs: iss-2609251600023777
Assisted-by: Claude:claude-opus-5-5
---
internal/core/mdrender/render.go | 66 +++++++++++++++++++++--
internal/core/site/fence_render_test.go | 71 +++++++++++++++++++++++++
2 files changed, 132 insertions(+), 5 deletions(-)
diff --git a/internal/core/mdrender/render.go b/internal/core/mdrender/render.go
index 56b6942cc..b3e9b3d06 100644
--- a/internal/core/mdrender/render.go
+++ b/internal/core/mdrender/render.go
@@ -18,6 +18,8 @@ import (
"fmt"
"regexp"
"strings"
+
+ "github.com/intentdriven/abcd/internal/core/mdrecord"
)
// UnsupportedError is a markdown construct outside the rendered subset.
@@ -155,6 +157,10 @@ func (r *Renderer) RenderBlocks(path string, blocks []Block) (string, error) {
// tilde run, or a backtick run longer than three, at any indent.
var unrenderedFenceRe = regexp.MustCompile("^[ \t]*(~{3,}|`{4,})")
+// indentedFenceRe matches a three-backtick fence opener at up to three spaces
+// of indent. Four or more spaces is an indented code block, refused on its own.
+var indentedFenceRe = regexp.MustCompile("^ {0,3}```")
+
// RenderBlock renders one top-level block.
func (r *Renderer) RenderBlock(path string, blk Block) (string, error) {
at := Source{Path: path, Line: blk.Line}
@@ -163,11 +169,12 @@ func (r *Renderer) RenderBlock(path string, blk Block) (string, error) {
// The walk that cut this block reads fences by mdrecord's rule, tildes and
// longer backtick runs included, and this renderer renders one form: a
- // three-backtick run at column 0. Any other form arriving here would render
- // as a paragraph, delimiters and code inlined into prose, with no error, so
- // it is refused. A three-backtick fence's own body is code and is not read
- // (iss-2609251514129841).
- if !strings.HasPrefix(first, "```") || strings.HasPrefix(first, "````") {
+ // three-backtick run at up to three spaces of indent. Any other form
+ // arriving here would render as a paragraph, delimiters and code inlined
+ // into prose, with no error, so it is refused. A three-backtick fence's own
+ // body is code and is not read (iss-2609251514129841).
+ opensFence := indentedFenceRe.MatchString(first) && !unrenderedFenceRe.MatchString(first)
+ if !opensFence {
for i, ln := range lines {
if unrenderedFenceRe.MatchString(ln) {
return "", &UnsupportedError{at.Path, at.Line + i, "fenced code block opened by a tilde or a run of four or more backticks",
@@ -176,6 +183,28 @@ func (r *Renderer) RenderBlock(path string, blk Block) (string, error) {
}
}
+ // A three-backtick opener indented one to three spaces opens a fence, by
+ // CommonMark and by the walk that cut this block: its lines lose up to that
+ // many spaces of indent and it renders as the fence at the margin does. It
+ // arrives here most often as the fence of a loose list item, cut from its
+ // item by the blank line above it. Rendered as a paragraph it inlined the
+ // delimiters and the code into prose (iss-2609251600023777).
+ if opensFence && !strings.HasPrefix(first, "```") {
+ return r.indentedFence(path, blk, lines)
+ }
+
+ // An indented opener below the first line of a block that is not a list —
+ // whose items the list renderer dedents and reads fences in — is a fence
+ // without a blank line before it, refused as the one at the margin is below.
+ if !opensFence && !IsUnorderedItem(first) && !OrderedItemRe.MatchString(first) {
+ for i, ln := range lines {
+ if indentedFenceRe.MatchString(ln) {
+ return "", &UnsupportedError{at.Path, at.Line + i, "fenced code block without a blank line before it",
+ "a fence opens its own block; without the blank line the code renders as part of the paragraph above"}
+ }
+ }
+ }
+
// A fence must open its own block. Without a blank line before it the block
// walk never sees it start, so the whole run — prose, backticks and code —
// arrives here as one paragraph, and every backtick would be escaped into the
@@ -287,6 +316,33 @@ func (r *Renderer) fence(at Source, lines []string) (string, error) {
`" data-copied="` + EscapeAttr(r.Labels.Copied) + `">` + EscapeText(r.Labels.Copy) + ``, nil
}
+// indentedFence renders a block opened by a three-backtick fence indented one
+// to three spaces. Each of the fence's lines loses up to the opener's indent
+// (CommonMark's rule for fenced content), and the fence then renders as one at
+// the margin does. The walk ends a block after a fence only when the fence
+// closes at the margin, so any lines after an indented closer run on in this
+// block; they render as the block they are.
+func (r *Renderer) indentedFence(path string, blk Block, lines []string) (string, error) {
+ end := len(lines)
+ if fs := mdrecord.Read(lines, mdrecord.TopLevel).Fences; len(fs) > 0 && fs[0].Start == 0 {
+ end = fs[0].End
+ }
+ n := IndentOf(lines[0])
+ fence := make([]string, end)
+ for i, ln := range lines[:end] {
+ fence[i] = ln[min(n, IndentOf(ln)):]
+ }
+ h, err := r.fence(Source{Path: path, Line: blk.Line}, fence)
+ if err != nil || end == len(lines) {
+ return h, err
+ }
+ rest, err := r.RenderBlock(path, Block{Text: strings.Join(lines[end:], "\n"), Line: blk.Line + end})
+ if err != nil {
+ return "", err
+ }
+ return h + rest, nil
+}
+
// heading renders an ATX heading.
func (r *Renderer) heading(at Source, line string) (string, error) {
m := HeadingRe.FindStringSubmatch(line)
diff --git a/internal/core/site/fence_render_test.go b/internal/core/site/fence_render_test.go
index 3382300ae..4e8a9e68b 100644
--- a/internal/core/site/fence_render_test.go
+++ b/internal/core/site/fence_render_test.go
@@ -2,6 +2,7 @@ package site
import (
"errors"
+ "strings"
"testing"
)
@@ -29,3 +30,73 @@ func TestRenderBlockRefusesAFenceFormItDoesNotRender(t *testing.T) {
t.Errorf("a three-backtick fence quoting a tilde line rendered nothing")
}
}
+
+// A three-backtick opener indented one to three spaces is a fence in
+// CommonMark, and mdrecord's walk reads it as one, but the renderer rendered a
+// three-backtick fence only at the left margin or inside a list item it
+// dedents: anywhere else the block became a paragraph holding inline code, the
+// delimiters and the code inlined into prose with no error. It most often
+// arrives as the fence of a loose list item, cut from the item by the blank
+// line above it. It renders as a fence, its lines losing up to the opener's
+// indent (iss-2609251600023777).
+func TestRenderBlockRendersAnIndentedThreeBacktickFence(t *testing.T) {
+ for name, tc := range map[string]struct{ md, code string }{
+ "one space": {" ```\ncode\n ```", "code\n"},
+ "two spaces": {" ```\n code\n more\n ```", "code\n more\n"},
+ "three spaces": {" ```\n code\n ```", "code\n"},
+ "three spaces with a language": {" ```sh\n abcd lint\n ```", `abcd lint` + "\n"},
+ "a body line indented less": {" ```\n code\n ```", "code\n"},
+ "a blank line in the body": {" ```\n one\n\n two\n ```", "one\n\ntwo\n"},
+ "after a blank line under an item": {"- item\n\n ```\n code\n ```", "code\n"},
+ } {
+ got, err := testRenderer().RenderBlocks("docs/page.md", Blocks(tc.md, 1))
+ if err != nil {
+ t.Errorf("%s: %v", name, err)
+ continue
+ }
+ if !strings.Contains(got, `
`+tc.code+`
`) {
+ t.Errorf("%s: rendered %s, want the command block holding %q", name, got, tc.code)
+ }
+ if strings.Contains(got, "
") {
+ t.Errorf("%s: rendered a paragraph: %s", name, got)
+ }
+ }
+ // Lines after an indented closer run on in the walk's block, and render as
+ // the block they are rather than as code.
+ got := render(t, "1. item\n\n ```\n code\n ```\n2. next")
+ if !strings.Contains(got, "code\n") || !strings.Contains(got, "
next") {
+ t.Errorf("the item after an indented closer lost its rendering: %s", got)
+ }
+}
+
+// The tilde twin of an indented fence is refused as the margin tilde fence is,
+// and an indented opener under a line of prose is a fence without a blank line
+// before it, refused as the margin one is.
+func TestRenderBlockRefusesTheIndentedFenceSiblings(t *testing.T) {
+ for name, md := range map[string]string{
+ "one space, the tilde twin": " ~~~\ncode\n ~~~",
+ "two spaces, the tilde twin": " ~~~\ncode\n ~~~",
+ "three spaces, the tilde twin": " ~~~\ncode\n ~~~",
+ "four backticks, indented": " ````\ncode\n ````",
+ "under a paragraph": "prose\n ```\ncode\n ```",
+ } {
+ _, err := testRenderer().RenderBlocks("docs/page.md", Blocks(md, 1))
+ var ue *UnsupportedError
+ if !errors.As(err, &ue) {
+ t.Errorf("%s: rendered without an UnsupportedError (err %v)", name, err)
+ }
+ }
+}
+
+// A fence at the margin quoting an indented backtick line is still a fence, and
+// a fence indented under the list item holding it still renders in the item.
+func TestRenderBlockKeepsTheFencesItAlreadyRendered(t *testing.T) {
+ for name, md := range map[string]string{
+ "a margin fence quoting an indented opener": "```md\n ```\n```",
+ "a fence inside a list item": "- item\n ```\n code\n ```",
+ } {
+ if got := render(t, md); !strings.Contains(got, `
`) {
+ t.Errorf("%s lost its command block: %s", name, got)
+ }
+ }
+}
From 5a599c499bb001ed799639856c53f01c575ef2ad Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:44:10 +0100
Subject: [PATCH 02/84] =?UTF-8?q?chore:=20resolve=20iss-2609251600023777?=
=?UTF-8?q?=20=E2=80=94=20an=20indented=20three-backtick=20fence=20renders?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves: iss-2609251600023777
Assisted-by: Claude:claude-opus-5-5
---
...level-three-backtick-fence-indented-one.md | 14 ------------
...level-three-backtick-fence-indented-one.md | 22 +++++++++++++++++++
2 files changed, 22 insertions(+), 14 deletions(-)
delete mode 100644 .abcd/work/issues/open/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md
create mode 100644 .abcd/work/issues/resolved/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md
diff --git a/.abcd/work/issues/open/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md b/.abcd/work/issues/open/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md
deleted file mode 100644
index 1f70f3973..000000000
--- a/.abcd/work/issues/open/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-schema_version: 1
-id: "iss-2609251600023777"
-slug: "site-renderer-a-top-level-three-backtick-fence-indented-one"
-severity: "minor"
-category: "bug"
-source: "impl-review"
-found_during: "autonomous run A resumed 2026-09-25"
-origin: researcher-authored
-production_mode: hand-written
-found_at: "internal/core/site/markdown.go"
----
-
-site renderer: a top-level three-backtick fence indented one to three spaces still renders silently as a paragraph with inline code (site/markdown.go unrenderedFenceRe covers tildes and four or more backticks only). No page in docs/ or site-src/ has the shape today. Refuse it like the other unsupported fence forms, or render it.
diff --git a/.abcd/work/issues/resolved/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md b/.abcd/work/issues/resolved/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md
new file mode 100644
index 000000000..1d863fc85
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2609251600023777-site-renderer-a-top-level-three-backtick-fence-indented-one.md
@@ -0,0 +1,22 @@
+---
+schema_version: 1
+id: "iss-2609251600023777"
+slug: "site-renderer-a-top-level-three-backtick-fence-indented-one"
+severity: "minor"
+category: "bug"
+source: "impl-review"
+found_during: "autonomous run A resumed 2026-09-25"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/site/markdown.go"
+resolution: "A top-level three-backtick fence indented one to three spaces renders as a fence, its lines losing up to the opener's indent (mdrender indentedFence); the tilde and four-backtick twins stay refused, and an indented opener under prose is refused as a fence without a blank line before it. The shape was live in record pages as the fence of a loose list item."
+impact: fix
+resolved_by:
+ commit: "b2fe9f27"
+---
+
+site renderer: a top-level three-backtick fence indented one to three spaces still renders silently as a paragraph with inline code (site/markdown.go unrenderedFenceRe covers tildes and four or more backticks only). No page in docs/ or site-src/ has the shape today. Refuse it like the other unsupported fence forms, or render it.
+
+## Grounds
+
+- pursued: every indent 1-3 renders a command block with dedented code and no paragraph (TestRenderBlockRendersAnIndentedThreeBacktickFence); a
for any of those inputs, or a site-render failure on the record, would show it wrong
From 2a05a0e119be1d17f66e4084bc80087a8c941a4d Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:48:14 +0100
Subject: [PATCH 03/84] fix(site): report the output directory without the home
path
Status.OutDir, Result.OutDir and CheckResult.OutDir carried the output
directory into `abcd site --json`, `site build --json` and
`lint site --json` as an absolute path: always for the build and the
check, which report the resolved directory, and for the board whenever
--out was absolute. Machine output never carries an absolute
developer-identity path (iss-81).
All three now go through displayOutDir: a directory inside the
repository is named relative to it (fsutil.RepoRel), one outside it has
the home directory redacted to "~" (fsutil.RedactHome), and a relative
--out is reported as given. The text board reads the same fields, so the
two renderings agree.
Refs: iss-2608291957114882, iss-81
Assisted-by: Claude:claude-opus-5-5
---
commands/site.md | 3 +-
internal/core/site/build.go | 6 +--
internal/core/site/check.go | 2 +-
internal/core/site/outdir.go | 15 +++++++
internal/core/site/outdir_display_test.go | 53 +++++++++++++++++++++++
5 files changed, 74 insertions(+), 5 deletions(-)
create mode 100644 internal/core/site/outdir_display_test.go
diff --git a/commands/site.md b/commands/site.md
index 96b6b71e6..02fd550fe 100644
--- a/commands/site.md
+++ b/commands/site.md
@@ -32,7 +32,8 @@ emits `{ "manifest": …, "ui_strings": …, "baseline": …, "out_dir": … }`:
- `baseline` and `baseline_entries` — the committed unresolved-reference
ratchet and its size.
- `version`, `commit` — what a render would stamp the footer with.
-- `out_dir`, `out_exists`, `out_files` — where a render writes, and what is
+- `out_dir`, `out_exists`, `out_files` — where a render writes (relative to the
+ repository inside it, with the home directory as `~` outside it), and what is
there now.
Report the declared inputs first, then the output directory's state. It writes
diff --git a/internal/core/site/build.go b/internal/core/site/build.go
index 531786458..de902a059 100644
--- a/internal/core/site/build.go
+++ b/internal/core/site/build.go
@@ -465,7 +465,7 @@ func Build(req Request) (Result, error) {
}
res := Result{
- OutDir: outDir,
+ OutDir: displayOutDir(repoRoot, outDir),
Records: len(export.Nodes),
Links: len(export.Edges),
Mentions: len(export.Mentions),
@@ -606,7 +606,7 @@ func Describe(repoRoot, outDir string) (Status, error) {
if outDir == "" {
outDir = DefaultOutDir
}
- st := Status{OutDir: outDir, Commit: HeadCommit(repoRoot), BaselinePath: BaselineRelPath}
+ st := Status{OutDir: displayOutDir(repoRoot, outDir), Commit: HeadCommit(repoRoot), BaselinePath: BaselineRelPath}
baselineRel := ""
m, err := LoadManifest(repoRoot)
switch {
@@ -660,7 +660,7 @@ func Describe(repoRoot, outDir string) (Status, error) {
if gerr != nil {
// Redacted at the source, so the text board and --json agree on this
// field: OutRefused never carries an absolute developer-identity path
- // (iss-81; OutDir itself is captured as iss-2608291957114882).
+ // (iss-81); OutDir reaches the same end through displayOutDir.
st.OutRefused = fsutil.RedactHome(fsutil.RedactRoot(gerr.Error(), repoRoot, "."))
return st, nil
}
diff --git a/internal/core/site/check.go b/internal/core/site/check.go
index b4167544b..801b75f58 100644
--- a/internal/core/site/check.go
+++ b/internal/core/site/check.go
@@ -275,7 +275,7 @@ func Check(req CheckRequest) (CheckResult, error) {
// surface, and a reader that has to tell `null` from `[]` is a reader that
// will one day get it wrong.
res := CheckResult{
- OutDir: outDir, Checks: CheckNames,
+ OutDir: displayOutDir(repoRoot, outDir), Checks: CheckNames,
Pages: []string{}, Composed: []string{},
Findings: []CheckFinding{}, Notes: []CheckFinding{},
}
diff --git a/internal/core/site/outdir.go b/internal/core/site/outdir.go
index bd840fb76..7214c9d1c 100644
--- a/internal/core/site/outdir.go
+++ b/internal/core/site/outdir.go
@@ -321,3 +321,18 @@ func refuseTrackedOutDir(outDir string) error {
}
return nil
}
+
+// displayOutDir is the output directory as the verbs report it. The report
+// travels into --json, and machine output never carries an absolute
+// developer-identity path (iss-81): a directory inside the repository is named
+// relative to it, and one outside it has the home directory redacted to "~". A
+// relative path is reported as it was given (iss-2608291957114882).
+func displayOutDir(repoRoot, outDir string) string {
+ if !filepath.IsAbs(outDir) {
+ return outDir
+ }
+ if rel, err := filepath.Rel(repoRoot, outDir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return filepath.ToSlash(fsutil.RepoRel(repoRoot, outDir))
+ }
+ return fsutil.RedactHome(outDir)
+}
diff --git a/internal/core/site/outdir_display_test.go b/internal/core/site/outdir_display_test.go
new file mode 100644
index 000000000..8ef632554
--- /dev/null
+++ b/internal/core/site/outdir_display_test.go
@@ -0,0 +1,53 @@
+package site
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// The output directory the site verbs report travels into `--json`, and machine
+// output never carries an absolute developer-identity path (iss-81). An output
+// directory inside the repository is reported relative to it; one outside it is
+// reported with the home directory redacted to "~", so an absolute --out under
+// the home — the shape that named the developer — reads as ~/… on every verb
+// (iss-2608291957114882).
+func TestTheSiteVerbsReportTheOutputDirectoryWithoutTheHomePath(t *testing.T) {
+ f := newFixture(t)
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ for name, tc := range map[string]struct{ out, want string }{
+ "inside the repository": {filepath.Join(f.Root(), "public"), "public"},
+ "under the home": {filepath.Join(home, "public"), "~/public"},
+ } {
+ st, err := Describe(f.Root(), tc.out)
+ if err != nil {
+ t.Fatalf("%s: describe: %v", name, err)
+ }
+ if st.OutDir != tc.want {
+ t.Errorf("%s: Status.OutDir = %q, want %q", name, st.OutDir, tc.want)
+ }
+
+ res := buildFixture(t, f, tc.out)
+ if res.OutDir != tc.want {
+ t.Errorf("%s: Result.OutDir = %q, want %q", name, res.OutDir, tc.want)
+ }
+
+ chk, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: tc.out})
+ if err != nil {
+ t.Fatalf("%s: check: %v", name, err)
+ }
+ if chk.OutDir != tc.want {
+ t.Errorf("%s: CheckResult.OutDir = %q, want %q", name, chk.OutDir, tc.want)
+ }
+ }
+
+ // A relative --out is reported as it was given.
+ st, err := Describe(f.Root(), DefaultOutDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.OutDir != DefaultOutDir {
+ t.Errorf("Status.OutDir = %q, want %q", st.OutDir, DefaultOutDir)
+ }
+}
From e0a5f82fb97f09c28603081da54c2ff7e36bf82e Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:50:03 +0100
Subject: [PATCH 04/84] chore(capture): the lifeboat and launch reports carry
absolute directories
Two siblings of the site verbs' OutDir leak, found in the sweep: the
lifeboat verbs' reports name the lifeboat, target and destination
directories absolutely, and launch ship's payload.dest does the same.
Refs: iss-2609261848326365, iss-2609261848338673
Assisted-by: Claude:claude-opus-5-5
---
...-verbs-carry-absolute-directories-into-their.md | 14 ++++++++++++++
...-json-and-launch-ship-s-payload-line-reports.md | 14 ++++++++++++++
2 files changed, 28 insertions(+)
create mode 100644 .abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
create mode 100644 .abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
diff --git a/.abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md b/.abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
new file mode 100644
index 000000000..2031a7adc
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
@@ -0,0 +1,14 @@
+---
+schema_version: 1
+id: "iss-2609261848326365"
+slug: "the-lifeboat-verbs-carry-absolute-directories-into-their"
+severity: "minor"
+category: "bug"
+source: "agent-finding"
+found_during: "autonomous run A resumed 2026-09-25"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/lifeboat/pack.go"
+---
+
+The lifeboat verbs carry absolute directories into their --json and text reports: PackResult.Dest (disembark pack), EmbarkPlan and EmbarkResult LifeboatDir and TargetDir (embark probe, embark from), LessonsResult.LifeboatDir (disembark graveyard), and PrinciplesResult, PressReleaseResult and ReviewResult LifeboatDir (disembark principles, press-release, review). A lifeboat or target directory under the home names the developer, and the iss-81 rule is that machine output never carries a developer-identity path; the site verbs' OutDir fields had the same shape (iss-2608291957114882).
diff --git a/.abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md b/.abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
new file mode 100644
index 000000000..ff8d340bb
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
@@ -0,0 +1,14 @@
+---
+schema_version: 1
+id: "iss-2609261848338673"
+slug: "launch-render-json-and-launch-ship-s-payload-line-reports"
+severity: "minor"
+category: "bug"
+source: "agent-finding"
+found_during: "autonomous run A resumed 2026-09-25"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/launch/render.go"
+---
+
+launch ship --json reports the release payload's destination as an absolute path in payload.dest, and the text report prints it on its payload line: PayloadRenderResult.Dest (internal/core/launch/render.go) is the symlink-resolved destination, so a destination under the home names the developer in machine output, against the iss-81 rule the site and lifeboat verbs are held to.
From 09f7dcdfe23a7179438e9e899cb9f74b3ac4fcb9 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:52:10 +0100
Subject: [PATCH 05/84] fix(lifeboat): report directories with the home
redacted
The lifeboat verbs named their directories absolutely in the --json and
text reports: PackResult.Dest, EmbarkPlan and EmbarkResult LifeboatDir
and TargetDir, LessonsResult.LifeboatDir, and the principles,
press-release and review results' LifeboatDir. A lifeboat or target
under the home named the developer, against the iss-81 rule.
Each field now goes through fsutil.RedactHome where the result is built,
so both renderings read the same value. The fields are display-only: no
code reads them back to reach the directory.
Refs: iss-2609261848326365, iss-81
Assisted-by: Claude:claude-opus-5-5
---
commands/disembark.md | 2 +-
internal/core/lifeboat/embark.go | 8 +-
internal/core/lifeboat/graveyard_lessons.go | 2 +-
internal/core/lifeboat/pack.go | 2 +-
internal/core/lifeboat/reportpaths_test.go | 76 +++++++++++++++++++
.../core/lifeboat/synthesis_pressrelease.go | 4 +-
.../core/lifeboat/synthesis_principles.go | 2 +-
internal/core/lifeboat/synthesis_review.go | 2 +-
8 files changed, 88 insertions(+), 10 deletions(-)
create mode 100644 internal/core/lifeboat/reportpaths_test.go
diff --git a/commands/disembark.md b/commands/disembark.md
index fd867d2db..b90e1f854 100644
--- a/commands/disembark.md
+++ b/commands/disembark.md
@@ -89,7 +89,7 @@ Each positional argument is a probe report emitted with `probe --json`.
Summarise the JSON result for the user:
-- `dest` — where the lifeboat was written.
+- `dest` — where the lifeboat was written, with the home directory as `~`.
- `files_written` / `bytes_written` — the size of the lifeboat.
- `manifest_sha256` — the pinned hash over every file (matches `/_provenance.json`).
- `voyage_appended` — whether the operator-level voyage ledger recorded the pack
diff --git a/internal/core/lifeboat/embark.go b/internal/core/lifeboat/embark.go
index 050917cff..0de05be49 100644
--- a/internal/core/lifeboat/embark.go
+++ b/internal/core/lifeboat/embark.go
@@ -51,8 +51,8 @@ func EmbarkProbe(lifeboatDir, targetDir string) (EmbarkPlan, error) {
marker := embarkMarker(pr.targetAbs, true)
return EmbarkPlan{
SchemaVersion: EmbarkSchemaVersion,
- LifeboatDir: pr.lifeboatAbs,
- TargetDir: pr.targetAbs,
+ LifeboatDir: fsutil.RedactHome(pr.lifeboatAbs),
+ TargetDir: fsutil.RedactHome(pr.targetAbs),
SourceName: pr.prov.SourceName,
ManifestVerified: true,
ManifestSHA256: pr.prov.ManifestSHA256,
@@ -77,8 +77,8 @@ func EmbarkFrom(lifeboatDir, targetDir string) (EmbarkResult, error) {
}
res := EmbarkResult{
SchemaVersion: EmbarkSchemaVersion,
- LifeboatDir: pr.lifeboatAbs,
- TargetDir: pr.targetAbs,
+ LifeboatDir: fsutil.RedactHome(pr.lifeboatAbs),
+ TargetDir: fsutil.RedactHome(pr.targetAbs),
SourceName: pr.prov.SourceName,
Coverage: pr.coverage,
Ignored: pr.ignored,
diff --git a/internal/core/lifeboat/graveyard_lessons.go b/internal/core/lifeboat/graveyard_lessons.go
index 31c15f73b..eab97a054 100644
--- a/internal/core/lifeboat/graveyard_lessons.go
+++ b/internal/core/lifeboat/graveyard_lessons.go
@@ -93,7 +93,7 @@ func IngestLessons(lifeboatDir string, raw []byte) (LessonsResult, error) {
}
// 4. Per-entry validation, drop-not-fatal.
- res := LessonsResult{LifeboatDir: abs}
+ res := LessonsResult{LifeboatDir: fsutil.RedactHome(abs)}
seen := map[string]bool{}
var mainLessons, lowLessons []Lesson
for _, in := range lf.Lessons {
diff --git a/internal/core/lifeboat/pack.go b/internal/core/lifeboat/pack.go
index 0eb8dbba1..808cb523b 100644
--- a/internal/core/lifeboat/pack.go
+++ b/internal/core/lifeboat/pack.go
@@ -104,7 +104,7 @@ func Pack(repoRoot, dest string, scan SecretScan, opts ...ProbeOption) (PackResu
}
res := PackResult{
- Dest: destAbs,
+ Dest: fsutil.RedactHome(destAbs),
SourceName: lb.Coverage.Repo.Name,
ManifestSHA256: ManifestSHA256(lb.Files),
FilesWritten: written,
diff --git a/internal/core/lifeboat/reportpaths_test.go b/internal/core/lifeboat/reportpaths_test.go
new file mode 100644
index 000000000..ff8367b33
--- /dev/null
+++ b/internal/core/lifeboat/reportpaths_test.go
@@ -0,0 +1,76 @@
+package lifeboat
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// Every lifeboat verb's report travels into --json, and machine output never
+// carries an absolute developer-identity path (iss-81). A lifeboat, a target
+// or a destination under the home directory is reported with the home redacted
+// to "~" (iss-2609261848326365).
+func TestLifeboatReportsNameDirectoriesWithoutTheHomePath(t *testing.T) {
+ src := embarkableSourceFixture(t)
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ lb := filepath.Join(home, "lifeboat")
+ target := filepath.Join(home, "target")
+ if err := os.Mkdir(target, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ check := func(what, got, want string) {
+ t.Helper()
+ if got != want {
+ t.Errorf("%s = %q, want %q", what, got, want)
+ }
+ }
+
+ pack, err := Pack(src, lb, okScan)
+ if err != nil {
+ t.Fatalf("Pack: %v", err)
+ }
+ check("PackResult.Dest", pack.Dest, "~/lifeboat")
+
+ plan, err := EmbarkProbe(lb, target)
+ if err != nil {
+ t.Fatalf("EmbarkProbe: %v", err)
+ }
+ check("EmbarkPlan.LifeboatDir", plan.LifeboatDir, "~/lifeboat")
+ check("EmbarkPlan.TargetDir", plan.TargetDir, "~/target")
+
+ res, err := EmbarkFrom(lb, target)
+ if err != nil {
+ t.Fatalf("EmbarkFrom: %v", err)
+ }
+ check("EmbarkResult.LifeboatDir", res.LifeboatDir, "~/lifeboat")
+ check("EmbarkResult.TargetDir", res.TargetDir, "~/target")
+
+ pr, err := SynthesizePrinciples(lb, nil)
+ if err != nil {
+ t.Fatalf("SynthesizePrinciples: %v", err)
+ }
+ check("PrinciplesResult.LifeboatDir", pr.LifeboatDir, "~/lifeboat")
+
+ press, err := ComposePressRelease(lb, nil)
+ if err != nil {
+ t.Fatalf("ComposePressRelease: %v", err)
+ }
+ check("PressReleaseResult.LifeboatDir", press.LifeboatDir, "~/lifeboat")
+
+ rev, err := ReviewLifeboat(lb, src, nil)
+ if err != nil {
+ t.Fatalf("ReviewLifeboat: %v", err)
+ }
+ check("ReviewResult.LifeboatDir", rev.LifeboatDir, "~/lifeboat")
+
+ grave := filepath.Join(home, "graveyard-lifeboat")
+ copyTree(t, stdFixture(t), grave)
+ les, err := IngestLessons(grave, payload(t, Lesson{ID: "les-engine-v1", Lesson: "engine v1 was retired",
+ Confidence: ConfidenceHigh, Evidence: []string{"rev-9f3a1c2d4e5b"}}))
+ if err != nil {
+ t.Fatalf("IngestLessons: %v", err)
+ }
+ check("LessonsResult.LifeboatDir", les.LifeboatDir, "~/graveyard-lifeboat")
+}
diff --git a/internal/core/lifeboat/synthesis_pressrelease.go b/internal/core/lifeboat/synthesis_pressrelease.go
index 33c751640..66d09547d 100644
--- a/internal/core/lifeboat/synthesis_pressrelease.go
+++ b/internal/core/lifeboat/synthesis_pressrelease.go
@@ -26,6 +26,8 @@ import (
"fmt"
"os"
"strings"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
)
// briefPressReleasePath is the packed brief's press-release section, the primary
@@ -88,7 +90,7 @@ func ComposePressRelease(lifeboatDir string, raw []byte) (PressReleaseResult, er
}
return PressReleaseResult{
- LifeboatDir: abs,
+ LifeboatDir: fsutil.RedactHome(abs),
Mode: file.Mode,
EvidenceRefs: len(file.Evidence),
PressReleasePath: "press-release.json",
diff --git a/internal/core/lifeboat/synthesis_principles.go b/internal/core/lifeboat/synthesis_principles.go
index 562cc9c6d..cdb2a6fbc 100644
--- a/internal/core/lifeboat/synthesis_principles.go
+++ b/internal/core/lifeboat/synthesis_principles.go
@@ -51,7 +51,7 @@ func SynthesizePrinciples(lifeboatDir string, raw []byte) (PrinciplesResult, err
return PrinciplesResult{}, err
}
- res := PrinciplesResult{LifeboatDir: abs}
+ res := PrinciplesResult{LifeboatDir: fsutil.RedactHome(abs)}
var (
principles []Principle
mode SynthesisMode
diff --git a/internal/core/lifeboat/synthesis_review.go b/internal/core/lifeboat/synthesis_review.go
index 43f861639..e785e6f38 100644
--- a/internal/core/lifeboat/synthesis_review.go
+++ b/internal/core/lifeboat/synthesis_review.go
@@ -123,7 +123,7 @@ func ReviewLifeboat(lifeboatDir, sourceRepo string, raw []byte) (ReviewResult, e
Coverage: cov.Summary,
}
- res := ReviewResult{LifeboatDir: abs}
+ res := ReviewResult{LifeboatDir: fsutil.RedactHome(abs)}
if raw == nil {
// --- deterministic mode -------------------------------------------------
From 9334ed23971d6617546337eac22f5c79661e099f Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:52:21 +0100
Subject: [PATCH 06/84] =?UTF-8?q?chore:=20resolve=20iss-2608291957114882?=
=?UTF-8?q?=20and=20iss-2609261848326365=20=E2=80=94=20site=20and=20lifebo?=
=?UTF-8?q?at=20report=20paths?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves: iss-2608291957114882
Resolves: iss-2609261848326365
Assisted-by: Claude:claude-opus-5-5
---
...sult-outdir-carry-an-absolute-path-into.md | 12 -----------
...sult-outdir-carry-an-absolute-path-into.md | 20 +++++++++++++++++++
...s-carry-absolute-directories-into-their.md | 8 ++++++++
3 files changed, 28 insertions(+), 12 deletions(-)
delete mode 100644 .abcd/work/issues/open/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md
create mode 100644 .abcd/work/issues/resolved/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md
rename .abcd/work/issues/{open => resolved}/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md (63%)
diff --git a/.abcd/work/issues/open/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md b/.abcd/work/issues/open/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md
deleted file mode 100644
index 3fd7277ee..000000000
--- a/.abcd/work/issues/open/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-schema_version: 1
-id: "iss-2608291957114882"
-slug: "status-outdir-and-result-outdir-carry-an-absolute-path-into"
-severity: "minor"
-category: "bug"
-source: "agent-finding"
-found_during: "v0.6.9-security-review"
-found_at: "internal/core/site/build.go"
----
-
-Status.OutDir and Result.OutDir carry an absolute path into abcd site --json and site build --json when --out is absolute; the iss-81 rule is that machine output never carries a developer-identity path and fsutil.RepoRel is the canonical primitive, unused here
diff --git a/.abcd/work/issues/resolved/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md b/.abcd/work/issues/resolved/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md
new file mode 100644
index 000000000..b1df16aed
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2608291957114882-status-outdir-and-result-outdir-carry-an-absolute-path-into.md
@@ -0,0 +1,20 @@
+---
+schema_version: 1
+id: "iss-2608291957114882"
+slug: "status-outdir-and-result-outdir-carry-an-absolute-path-into"
+severity: "minor"
+category: "bug"
+source: "agent-finding"
+found_during: "v0.6.9-security-review"
+found_at: "internal/core/site/build.go"
+resolution: "Status.OutDir, Result.OutDir and CheckResult.OutDir go through site.displayOutDir: repo-relative inside the repository (fsutil.RepoRel), home redacted to ~ outside it (fsutil.RedactHome), a relative --out as given. The lifeboat sweep is iss-2609261848326365; launch ship's payload.dest is captured as iss-2609261848338673."
+impact: fix
+resolved_by:
+ commit: "2a05a0e1"
+---
+
+Status.OutDir and Result.OutDir carry an absolute path into abcd site --json and site build --json when --out is absolute; the iss-81 rule is that machine output never carries a developer-identity path and fsutil.RepoRel is the canonical primitive, unused here
+
+## Grounds
+
+- pursued: an absolute --out inside the repo reports as its repo-relative path and one under HOME as ~/…, on the board, the build and the check (TestTheSiteVerbsReportTheOutputDirectoryWithoutTheHomePath); an absolute path in any of the three would show it wrong
diff --git a/.abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md b/.abcd/work/issues/resolved/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
similarity index 63%
rename from .abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
rename to .abcd/work/issues/resolved/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
index 2031a7adc..3823d488c 100644
--- a/.abcd/work/issues/open/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.md
+++ b/.abcd/work/issues/resolved/iss-2609261848326365-the-lifeboat-verbs-carry-absolute-directories-into-their.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/lifeboat/pack.go"
+resolution: "Every lifeboat report directory field (pack dest, embark lifeboat_dir and target_dir, graveyard lessons, principles, press-release and review lifeboat_dir) goes through fsutil.RedactHome where the result is built."
+impact: fix
+resolved_by:
+ commit: "09f7dcdf"
---
The lifeboat verbs carry absolute directories into their --json and text reports: PackResult.Dest (disembark pack), EmbarkPlan and EmbarkResult LifeboatDir and TargetDir (embark probe, embark from), LessonsResult.LifeboatDir (disembark graveyard), and PrinciplesResult, PressReleaseResult and ReviewResult LifeboatDir (disembark principles, press-release, review). A lifeboat or target directory under the home names the developer, and the iss-81 rule is that machine output never carries a developer-identity path; the site verbs' OutDir fields had the same shape (iss-2608291957114882).
+
+## Grounds
+
+- pursued: each of the nine fields reports a directory under HOME as ~/… (TestLifeboatReportsNameDirectoriesWithoutTheHomePath, watched failing on all nine at the base); an absolute home path in any lifeboat --json would show it wrong
From 52329d9f54732e73c4b438087e771225d38e03ce Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:55:10 +0100
Subject: [PATCH 07/84] feat(site): the health page carries the disclosure rate
The contributors rethink kept the page to its two folded panels, the
authors of record and the Assisted-by trailers, and dropped the three
stat tiles above them, with the None declaration counted beside the
chart rather than inside it. The share of authored commits that
disclose AI assistance was to move to the health page, and the
contributors page's own comment says it lives there, but no page
rendered it: its interface string sat unread in ui.json.
The rate is now the last tile in the health page's row of counts: the
same figure the contributors page carried, assisted commits over
authored commits with the merges set aside stated beside it.
Refs: iss-2608231008315498
Assisted-by: Claude:claude-opus-5-5
---
internal/core/site/health.go | 30 ++++++++++++++++++++++++++
internal/core/site/health_test.go | 36 +++++++++++++++++++++++++++++++
2 files changed, 66 insertions(+)
diff --git a/internal/core/site/health.go b/internal/core/site/health.go
index 77f3d4f76..20ded5993 100644
--- a/internal/core/site/health.go
+++ b/internal/core/site/health.go
@@ -70,6 +70,7 @@ func (e *explorer) healthPage() (string, error) {
for _, t := range tiles {
b.WriteString(tile(strconv.Itoa(t.n), t.label, nil))
}
+ b.WriteString(e.healthDisclosureRate())
b.WriteString(`
`)
b.WriteString(`
`)
@@ -83,6 +84,35 @@ func (e *explorer) healthPage() (string, error) {
return e.shell(routeHealth, e.c.ui.RecordNav.Health, "", b.String()), nil
}
+// healthDisclosureRate is the share of authored commits that disclose AI
+// assistance, the last tile in the row of counts. The contributors page carries
+// the authors and the trailers alone; the rate sits here, with the findings the
+// trailer tally is checked against (iss-2608231008315498).
+//
+// The rate is COMMITS that disclose over commits a person WROTE. Merges are in
+// neither: the forge writes them, no convention asks them to declare anything,
+// and leaving them in the denominator understated this rate by more than twenty
+// points. The excluded count is shown, not assumed. A history with no authored
+// commit has no rate, and renders no tile.
+func (e *explorer) healthDisclosureRate() string {
+ ui := e.c.ui
+ a := e.export.Authorship
+ if a.Authored == 0 {
+ return ""
+ }
+ share := strconv.Itoa(a.AssistedCommits*100/a.Authored) + "%"
+ // The count is its own element rather than part of the sentence: the
+ // provenance walk splits composed text on decorations only, and a number
+ // glued to a phrase is neither a number nor an interface string.
+ var excl string
+ if a.Merges > 0 {
+ excl = `
` + strconv.Itoa(a.Merges) + ` ` +
+ escapeText(ui.Contributors.MergesExcluded) + ``
+ }
+ return tileExtra(share, ui.Contributors.Assisted,
+ []string{strconv.Itoa(a.AssistedCommits) + " / " + strconv.Itoa(a.Authored)}, excl)
+}
+
// healthSupersedes reads the supersession edges as text: which record replaced
// which, and which replacement points at something the tree no longer holds.
//
diff --git a/internal/core/site/health_test.go b/internal/core/site/health_test.go
index 0ae342366..92ab0fb94 100644
--- a/internal/core/site/health_test.go
+++ b/internal/core/site/health_test.go
@@ -283,3 +283,39 @@ func sliceBetween(s, from, to string) string {
}
return rest[:j]
}
+
+// TestHealthCarriesTheDisclosureRate pins where the disclosure rate lives. The
+// contributors page carries two things only — the authors of record and the
+// Assisted-by trailers — and the share of authored commits that disclose AI
+// assistance sits with the other findings, on the health page's row of counts,
+// as COMMITS that disclose over commits a person wrote, with the merges set
+// aside stated (iss-2608231008315498).
+func TestHealthCarriesTheDisclosureRate(t *testing.T) {
+ f := newFixture(t)
+ out := t.TempDir()
+ buildFixture(t, f, out)
+
+ a := decodeExport(t, out).Authorship
+ if a.Authored == 0 || a.AssistedCommits == 0 || a.Merges == 0 {
+ t.Fatalf("the fixture must carry authored, assisted and merge commits: %+v", a)
+ }
+ share := strconv.Itoa(a.AssistedCommits*100/a.Authored) + "%"
+ want := `
` + share + `of authored commits disclose AI assistance` +
+ `
` + strconv.Itoa(a.AssistedCommits) + ` / ` + strconv.Itoa(a.Authored) + `` +
+ `
` + strconv.Itoa(a.Merges) + ` merge commits excluded`
+
+ health := outFile(t, out, "record/health/index.html")
+ if !strings.Contains(health, want) {
+ t.Errorf("the health page does not carry the disclosure rate %s", want)
+ }
+ contributors := outFile(t, out, "contributors/index.html")
+ if strings.Contains(contributors, "disclose AI assistance") {
+ t.Error("the contributors page still carries the disclosure rate")
+ }
+ if n := strings.Count(contributors, `class="panel fold c12"`); n != 2 {
+ t.Errorf("the contributors page carries %d folded full-width panels, want the two: authors and trailers", n)
+ }
+ if strings.Contains(contributors, `class="tile`) {
+ t.Error("the contributors page carries a stat tile")
+ }
+}
From fd59d1114dc80d75010142949ee9b77f3cc7b460 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:55:18 +0100
Subject: [PATCH 08/84] =?UTF-8?q?chore:=20resolve=20iss-2608231008315498?=
=?UTF-8?q?=20=E2=80=94=20the=20contributors=20rethink=20is=20complete?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves: iss-2608231008315498
Assisted-by: Claude:claude-opus-5-5
---
...ibutors-page-rethink-and-assisted-total-mismatch.md | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
rename .abcd/work/issues/{open => resolved}/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md (56%)
diff --git a/.abcd/work/issues/open/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md b/.abcd/work/issues/resolved/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md
similarity index 56%
rename from .abcd/work/issues/open/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md
rename to .abcd/work/issues/resolved/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md
index a3d0d4efc..fa73fe0a0 100644
--- a/.abcd/work/issues/open/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md
+++ b/.abcd/work/issues/resolved/iss-2608231008315498-site-contributors-page-rethink-and-assisted-total-mismatch.md
@@ -7,6 +7,14 @@ category: "bug"
source: "user-observation"
found_during: "user-observation"
found_at: "internal/core/site/explorer.go"
+resolution: "The contributors page carries only its two folded full-width panels, Authors of record and Assisted-by trailers, with no stat tiles, and the None declaration is counted beneath the chart, not in it, so the panel total equals the bar sum (bed0026c; pinned by TestAuthorshipBarSumEqualsAssistedTotal). The last piece, the disclosure rate moving to the health page, lands in 52329d9f as the last tile of the health page's row of counts."
+impact: fix
+resolved_by:
+ commit: "52329d9f"
---
-Contributors page rethink: keep only the two things that matter — Authors of record, and Assisted-by trailers — stacked one under the other as expandable panels; drop the three stat tiles, with 'commits disclose AI assistance' moving to the Health page (depends on that page landing with the IA intent seed iss-2608230752354909). Also a real data inconsistency: the Assisted-by panel's note reads 1035 (a.Assisted) while its bars sum to 1036, because the tally includes the 'None' row — the positive human-only declaration — which a.Assisted deliberately excludes (contributors.go counts DeclaredNone and continues before incrementing Assisted). A chart whose total is 'assisted' must not carry a row that is by definition not assistance: either split None out as its own labelled figure or relabel the panel to the declarations it actually counts, and make the note equal what the bars sum to (report D of the 2026-08-23 second pass).
\ No newline at end of file
+Contributors page rethink: keep only the two things that matter — Authors of record, and Assisted-by trailers — stacked one under the other as expandable panels; drop the three stat tiles, with 'commits disclose AI assistance' moving to the Health page (depends on that page landing with the IA intent seed iss-2608230752354909). Also a real data inconsistency: the Assisted-by panel's note reads 1035 (a.Assisted) while its bars sum to 1036, because the tally includes the 'None' row — the positive human-only declaration — which a.Assisted deliberately excludes (contributors.go counts DeclaredNone and continues before incrementing Assisted). A chart whose total is 'assisted' must not carry a row that is by definition not assistance: either split None out as its own labelled figure or relabel the panel to the declarations it actually counts, and make the note equal what the bars sum to (report D of the 2026-08-23 second pass).
+
+## Grounds
+
+- pursued: the health page carries the share of authored commits disclosing assistance with its fraction and the merges excluded, and the contributors page carries two folded panels and no tile (TestHealthCarriesTheDisclosureRate); a tile on the contributors page, or no rate on the health page, would show it wrong
From 5b56c19dbbcfd20534ff6d020af18b60c1f49b40 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:05:49 +0100
Subject: [PATCH 09/84] feat(site): read the community-health files from
.github/ as the forge does
The forge reads a repository's contribution guide and security policy
from .github/, the root or docs/. The site and the lifeboat probe read
the root alone, so a repository that keeps its root clear lost its
footer security link silently and could not name its contribution
guide as the contributors page's policy source.
- The footer links the security policy at the first of .github/, the
root and docs/ that carries it, by its file name; the provenance gate
reads that name through the same resolution (footerLinks).
- record_pages.contributors.policy.file admits a markdown file directly
in .github/, and nothing else there: the forge configuration beside it
stays refused.
- The lifeboat's conventions tier counts .github/CONTRIBUTING.md.
Refs: iss-2608270540523859
Assisted-by: Claude:claude-opus-5-5
---
internal/core/lifeboat/probe.go | 2 +
.../core/lifeboat/sources_conventions_test.go | 23 ++++++
internal/core/site/check.go | 6 ++
internal/core/site/communityhealth_test.go | 76 +++++++++++++++++++
internal/core/site/compose.go | 35 +++++++--
internal/core/site/manifest.go | 29 +++++--
internal/core/site/manifest_test.go | 7 ++
7 files changed, 166 insertions(+), 12 deletions(-)
create mode 100644 internal/core/site/communityhealth_test.go
diff --git a/internal/core/lifeboat/probe.go b/internal/core/lifeboat/probe.go
index 74dae4d76..9b889025d 100644
--- a/internal/core/lifeboat/probe.go
+++ b/internal/core/lifeboat/probe.go
@@ -914,6 +914,8 @@ func tiersPresent(c *SourceContext) []Tier {
func hasConventions(c *SourceContext) bool {
candidates := []string{
"docs", "LICENSE", "LICENSE.md", "CONTRIBUTING.md", "CONTRIBUTING",
+ // The forge reads the contribution guide from .github/ as well as the root.
+ ".github/CONTRIBUTING.md",
"ISSUES.md", "ISSUES",
// Directory evidence the adapters treat as grounding.
".github/workflows", "issues", ".github/ISSUE_TEMPLATE",
diff --git a/internal/core/lifeboat/sources_conventions_test.go b/internal/core/lifeboat/sources_conventions_test.go
index 0fb77d833..dc7ae5df9 100644
--- a/internal/core/lifeboat/sources_conventions_test.go
+++ b/internal/core/lifeboat/sources_conventions_test.go
@@ -1081,3 +1081,26 @@ func containsSource(sources []string, want string) bool {
}
return false
}
+
+// TestHasConventionsReadsTheGithubContributionGuide: the forge reads a
+// repository's contribution guide from `.github/` as readily as from the root,
+// so a repository whose only convention document is `.github/CONTRIBUTING.md`
+// still carries the conventions tier (iss-2608270540523859).
+func TestHasConventionsReadsTheGithubContributionGuide(t *testing.T) {
+ dir := t.TempDir()
+ full := filepath.Join(dir, ".github", "CONTRIBUTING.md")
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte("# Contributing\n\nOpen a pull request.\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ ctx, err := newSourceContext(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ctx.Close()
+ if !hasConventions(ctx) {
+ t.Fatal("hasConventions=false for a repository whose contribution guide is in .github/")
+ }
+}
diff --git a/internal/core/site/check.go b/internal/core/site/check.go
index 801b75f58..5df4158ab 100644
--- a/internal/core/site/check.go
+++ b/internal/core/site/check.go
@@ -811,6 +811,12 @@ func (c *checker) generatorWords() generatorWords {
addName(a)
}
addName(AssetChecksums)
+ // The footer names each file it links by its base name, wherever the
+ // repository keeps it: the security policy may live in .github/, where
+ // the name alone resolves to nothing at the root.
+ for _, f := range footerLinks(c.root) {
+ addName(path.Base(f))
+ }
g.exists = func(token string) bool {
if !fsutil.ValidRelPath(token) {
diff --git a/internal/core/site/communityhealth_test.go b/internal/core/site/communityhealth_test.go
new file mode 100644
index 000000000..bf0b73149
--- /dev/null
+++ b/internal/core/site/communityhealth_test.go
@@ -0,0 +1,76 @@
+package site
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// The forge reads a repository's community-health files — the contribution
+// guide and the security policy among them — from `.github/` as readily as from
+// the root, and a repository that keeps its root clear moves them there. The
+// site follows them: the footer links the security policy where it lives, and
+// the contributors page quotes a policy the manifest names in `.github/`
+// (iss-2608270540523859).
+func TestTheSiteReadsCommunityHealthFilesFromTheGithubDirectory(t *testing.T) {
+ f := newFixture(t)
+ for _, name := range []string{"SECURITY.md", "CONTRIBUTING.md"} {
+ data, err := os.ReadFile(filepath.Join(f.Root(), name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(f.Root(), name)); err != nil {
+ t.Fatal(err)
+ }
+ f.write(".github/"+name, string(data))
+ }
+ repointManifest(t, f, `"file": "CONTRIBUTING.md"`, `"file": ".github/CONTRIBUTING.md"`)
+
+ out := t.TempDir()
+ buildFixture(t, f, out)
+
+ const forge = "https://example.invalid/fixture/repo/blob/main/"
+ index := outFile(t, out, "index.html")
+ if !strings.Contains(index, `
SECURITY.md`) {
+ t.Error("the footer does not link the security policy in .github/")
+ }
+ if strings.Contains(index, forge+"SECURITY.md") {
+ t.Error("the footer links a root security policy the repository does not carry")
+ }
+ contributors := outFile(t, out, "contributors/index.html")
+ for _, want := range []string{
+ `data-src=".github/CONTRIBUTING.md#attribution"`,
+ `
`,
+ "Human author of record.",
+ } {
+ if !strings.Contains(contributors, want) {
+ t.Errorf("the contributors page does not carry %q", want)
+ }
+ }
+}
+
+// The footer names the security policy by its file name wherever it lives, and
+// the provenance gate reads that name as the file the footer resolved, not as a
+// root path the repository no longer carries (iss-2608270540523859).
+func TestTheProvenanceGateReadsTheFooterFileItResolved(t *testing.T) {
+ f := newFixture(t)
+ data, err := os.ReadFile(filepath.Join(f.Root(), "SECURITY.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(f.Root(), "SECURITY.md")); err != nil {
+ t.Fatal(err)
+ }
+ f.write(".github/SECURITY.md", string(data))
+
+ res, err := Check(CheckRequest{RepoRoot: f.Root(), OutDir: t.TempDir()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, fd := range res.Findings {
+ if strings.Contains(fd.Detail, `"SECURITY.md"`) {
+ t.Errorf("the gate refused the footer's security link: %+v", fd)
+ }
+ }
+}
diff --git a/internal/core/site/compose.go b/internal/core/site/compose.go
index 914460f2c..4800b3743 100644
--- a/internal/core/site/compose.go
+++ b/internal/core/site/compose.go
@@ -378,6 +378,34 @@ func (c *composer) installChapterAnchor() string {
return c.firstChapterAnchor()
}
+// footerFiles are the repository files the footer links, in order, each by the
+// first of its locations the repository carries. The security policy is a
+// community-health file, which the forge reads from `.github/`, the root or
+// `docs/`, in that order, so the footer looks where the forge looks; the other
+// three are read at the root alone.
+var footerFiles = [][]string{
+ {".github/SECURITY.md", "SECURITY.md", "docs/SECURITY.md"},
+ {"ACKNOWLEDGEMENTS.md"},
+ {"CITATION.cff"},
+ {"CHANGELOG.md"},
+}
+
+// footerLinks resolves footerFiles against the repository: each file at the
+// first of its locations the repository carries, in order. The footer and the
+// provenance gate that reads its link text resolve through this one walk.
+func footerLinks(root *os.Root) []string {
+ var out []string
+ for _, candidates := range footerFiles {
+ for _, f := range candidates {
+ if _, err := root.Stat(f); err == nil {
+ out = append(out, f)
+ break
+ }
+ }
+ }
+ return out
+}
+
// footer renders the site footer: file names, links, and build metadata only.
func (c *composer) footer() string {
var b strings.Builder
@@ -398,11 +426,8 @@ func (c *composer) footer() string {
` ` + escapeText(c.repo.License) + ``)
}
if c.repo.Repository != "" {
- for _, f := range []string{"SECURITY.md", "ACKNOWLEDGEMENTS.md", "CITATION.cff", "CHANGELOG.md"} {
- if _, err := c.root.Stat(f); err != nil {
- continue
- }
- b.WriteString(`` + escapeText(f) + ``)
+ for _, f := range footerLinks(c.root) {
+ b.WriteString(`
` + escapeText(path.Base(f)) + ``)
}
b.WriteString(`
` + escapeText(c.forgeLabel()) + ``)
}
diff --git a/internal/core/site/manifest.go b/internal/core/site/manifest.go
index 64db72314..ddf5f1be5 100644
--- a/internal/core/site/manifest.go
+++ b/internal/core/site/manifest.go
@@ -63,6 +63,9 @@ type quoteSource struct {
directlyIn []string
// rootMarkdown admits a markdown file at the repository root itself.
rootMarkdown bool
+ // markdownIn admits a markdown file sitting DIRECTLY in one of these
+ // directories, and no other file there.
+ markdownIn []string
// what says, in the refusal, what the field is for — the reader's next move
// is an edit to this key, and the reason it is fenced is the useful half.
what string
@@ -91,14 +94,16 @@ var (
under: []string{"docs/"},
what: "it names a documentation page the site links",
}
- // record_pages.contributors.policy.file: the contribution policy
- // conventionally sits at the repository ROOT, which the page roots do not
- // cover, so the set adds a markdown file at the root itself. The root holds
- // committed, forge-published prose and no gitignored tier; `.env` and
- // `.git/config` are not markdown and stay refused.
+ // record_pages.contributors.policy.file: the contribution policy is a
+ // community-health file, which the forge reads from the repository ROOT or
+ // from `.github/`, and neither is a page root, so the set adds a markdown
+ // file directly in either. Both hold committed, forge-published prose and no
+ // gitignored tier; `.env`, `.git/config` and the forge configuration beside
+ // the markdown in `.github/` are not markdown and stay refused.
policySource = quoteSource{
under: []string{"docs/", "site-src/"},
rootMarkdown: true,
+ markdownIn: []string{".github/"},
what: "the contributors page publishes the section it selects, verbatim",
}
// checks.unresolved_reference_baseline is CONFIGURATION rather than prose:
@@ -125,9 +130,15 @@ func (q quoteSource) admits(p string) bool {
return true
}
}
- if q.rootMarkdown && !strings.Contains(p, "/") && strings.HasSuffix(strings.ToLower(p), ".md") {
+ isMarkdown := strings.HasSuffix(strings.ToLower(p), ".md")
+ if q.rootMarkdown && !strings.Contains(p, "/") && isMarkdown {
return true
}
+ for _, dir := range q.markdownIn {
+ if rest, ok := strings.CutPrefix(p, dir); ok && isMarkdown && !strings.Contains(rest, "/") {
+ return true
+ }
+ }
return false
}
@@ -141,6 +152,9 @@ func (q quoteSource) describe() string {
if q.rootMarkdown {
parts = append(parts, "a markdown file at the repository root")
}
+ for _, dir := range q.markdownIn {
+ parts = append(parts, "a markdown file directly in "+dir)
+ }
return strings.Join(parts, " or ")
}
@@ -486,7 +500,8 @@ func (m Manifest) validateDeferred(bad func(string, ...any) error) error {
return bad("record_pages.contributors.policy.file %q is not a repo-relative path", policy.File)
}
// A quote, not a whole-file page source, so the attribution policy stays
- // quotable from CONTRIBUTING.md at the repository root — but from
+ // quotable from a contribution guide at the repository root or in
+ // .github/ — but from
// nowhere the page roots and that root allowance do not cover. This is
// the field with the widest blast radius: policyQuote publishes the
// whole matched section whenever `part` is not first-bullet.
diff --git a/internal/core/site/manifest_test.go b/internal/core/site/manifest_test.go
index cc91ae609..374846286 100644
--- a/internal/core/site/manifest_test.go
+++ b/internal/core/site/manifest_test.go
@@ -150,6 +150,12 @@ func TestManifestRefusesQuoteSourcesOutsideTheirRoots(t *testing.T) {
{"contributors policy in the working tier", policy,
`"file": ".abcd/work/CONTEXT.md"`, ".abcd/work/CONTEXT.md"},
{"contributors policy at a repository-root dotfile", policy, `"file": ".env"`, ".env"},
+ // .github/ admits a markdown file directly in it, where the forge reads
+ // community-health files, and nothing else there.
+ {"contributors policy as forge configuration", policy,
+ `"file": ".github/dependabot.yml"`, ".github/dependabot.yml"},
+ {"contributors policy below the community-health directory", policy,
+ `"file": ".github/ISSUE_TEMPLATE/bug.md"`, ".github/ISSUE_TEMPLATE/bug.md"},
{"identity block in the local tier", identity,
`"file": ".abcd/.work.local/scratch/identity.md"`, ".abcd/.work.local/scratch/identity.md"},
{"identity block at a repository-root dotfile", identity, `"file": ".env"`, ".env"},
@@ -181,6 +187,7 @@ func TestManifestRefusesQuoteSourcesOutsideTheirRoots(t *testing.T) {
{"the identity block in the durable record", identity,
`"file": ".abcd/development/brief/01-product/README.md"`},
{"the contributors policy at the repository root", policy, `"file": "CONTRIBUTING.md"`},
+ {"the contributors policy in the community-health directory", policy, `"file": ".github/CONTRIBUTING.md"`},
{"a documentation page as the policy source", policy, `"file": "docs/README.md"`},
// The baseline is held to the manifest's own directory and no deeper,
// so a repository may name a baseline it has yet to write.
From 4d4bbfb3e30cf4c262eaff061577101f727d4bab Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:05:56 +0100
Subject: [PATCH 10/84] refactor: move CONTRIBUTING.md and SECURITY.md into
.github/
The repository root carries the files a reader or a tool looks for
there; the contribution guide and the security policy are community-
health files, which the forge reads from .github/ as readily, so they
move there with every reader in the same change:
- .abcd/site.json names .github/CONTRIBUTING.md as the contributors
page's policy source; the footer finds .github/SECURITY.md itself.
- .abcd/docs-lint.json drops CONTRIBUTING and SECURITY from the
stray_root_docs allowlist, so neither can drift back to the root.
- README and ACKNOWLEDGEMENTS link the new paths; the guide's own
relative links step up a directory.
- CI's inert-path classifier names the two files at their new home.
- The preflight-gates and format-gate tests read the guide there.
- AGENTS.md, the PR template, the attribution workflow and gate, the
site command page, the site and launch brief chapters and one
principle name the new path.
The two files now sit under the /.github/ CODEOWNERS entry, so a change
to either takes a code-owner review.
Refs: iss-2608270540523859
Assisted-by: Claude:claude-opus-5-5
---
.../brief/04-surfaces/04-launch.md | 2 +-
.../development/brief/04-surfaces/22-site.md | 7 ++---
.../principles/adopt-contributor-commits.md | 2 +-
.abcd/docs-lint.json | 2 --
.abcd/site.json | 2 +-
CONTRIBUTING.md => .github/CONTRIBUTING.md | 26 +++++++++----------
.github/PULL_REQUEST_TEMPLATE.md | 2 +-
SECURITY.md => .github/SECURITY.md | 0
.github/workflows/attribution.yml | 2 +-
.github/workflows/ci.yml | 8 +++---
ACKNOWLEDGEMENTS.md | 2 +-
AGENTS.md | 9 ++++---
README.md | 2 +-
commands/site.md | 11 ++++----
internal/core/lint/preflightgates_test.go | 14 +++++-----
scripts/check-attribution.sh | 4 +--
16 files changed, 49 insertions(+), 46 deletions(-)
rename CONTRIBUTING.md => .github/CONTRIBUTING.md (92%)
rename SECURITY.md => .github/SECURITY.md (100%)
diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md
index 782123330..4e42ff11a 100644
--- a/.abcd/development/brief/04-surfaces/04-launch.md
+++ b/.abcd/development/brief/04-surfaces/04-launch.md
@@ -535,7 +535,7 @@ fingerprinted — not the unversioned working tree.
older harnesses fail to install it, and very old ones fail to load the
marketplace. The install instructions and the release notes state the floor.
- **Contributors** load the plugin from their own checkout rather than through a
- second catalog entry (`CONTRIBUTING.md`).
+ second catalog entry (`.github/CONTRIBUTING.md`).
**Anti-drift.** The two manifests in the artefact describe one release, so the
version at the selected location and the marketplace entry must agree. A
diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md
index e2d1bbc51..463f68541 100644
--- a/.abcd/development/brief/04-surfaces/22-site.md
+++ b/.abcd/development/brief/04-surfaces/22-site.md
@@ -141,9 +141,10 @@ The build reads the repository and nothing else — no network at any point. Its
inputs are the composition declaration and the interface-string allowlist; the
record itself, read through the record-lint engine's own frontmatter scan so there
is one parser rather than two; the bibliography and the glossary through their own
-parsers; one pass of git history; `CHANGELOG.md`; the two root prose files whose
-text the site publishes, which are the acknowledgements behind the references page
-and the authorship section of the contribution guide behind the contributors page;
+parsers; one pass of git history; `CHANGELOG.md`; the two prose files whose
+text the site publishes, which are the acknowledgements at the root behind the
+references page and the authorship section of the contribution guide in `.github/`
+behind the contributors page;
and `docs/` with its committed assets. It writes the landing page, the record explorer, the machine-readable
record export, the install script from its committed template, the redirect and
header maps, the stylesheets and scripts, every referenced raster, and its own
diff --git a/.abcd/development/principles/adopt-contributor-commits.md b/.abcd/development/principles/adopt-contributor-commits.md
index 5cb891db4..de1357bdf 100644
--- a/.abcd/development/principles/adopt-contributor-commits.md
+++ b/.abcd/development/principles/adopt-contributor-commits.md
@@ -12,6 +12,6 @@ the same diff — a missing entry is a follow-up debt, not a separate decision.
Surfaced by the second operator in the 2026-08-27 security-advisory pilot
(F-W): the issue-sweep's re-author-with-`Reported-by` default cost a
contributor with a ready branch their contributor-graph authorship. The
-enabling convention beneath this principle is `CONTRIBUTING.md`'s attribution
+enabling convention beneath this principle is `.github/CONTRIBUTING.md`'s attribution
section; the discipline rung (a gate that notices an adopted-and-rewritten
external branch) is unfiled.
diff --git a/.abcd/docs-lint.json b/.abcd/docs-lint.json
index df0d818c5..66813a849 100644
--- a/.abcd/docs-lint.json
+++ b/.abcd/docs-lint.json
@@ -262,8 +262,6 @@
"AGENTS",
"CHANGELOG",
"RELEASE",
- "CONTRIBUTING",
- "SECURITY",
"LICENSE",
"ACKNOWLEDGEMENTS"
]
diff --git a/.abcd/site.json b/.abcd/site.json
index d845c6b46..5e9162441 100644
--- a/.abcd/site.json
+++ b/.abcd/site.json
@@ -68,7 +68,7 @@
"record_pages": {
"contributors": {
"policy": {
- "file": "CONTRIBUTING.md",
+ "file": ".github/CONTRIBUTING.md",
"heading": "AI assistance and authorship",
"part": "first-bullet"
}
diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md
similarity index 92%
rename from CONTRIBUTING.md
rename to .github/CONTRIBUTING.md
index 42377e3f9..28fb02d40 100644
--- a/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,13 +1,13 @@
# Contributing
-abcd is a public project under active development. See [`AGENTS.md`](AGENTS.md)
+abcd is a public project under active development. See [`AGENTS.md`](../AGENTS.md)
for build/test/checks and working conventions, and
-[`.abcd/development/`](.abcd/development/) for the design record.
+[`.abcd/development/`](../.abcd/development/) for the design record.
## Licence
Contributions are accepted under the project's licence, inbound = outbound: by
-submitting a change you agree it is licensed under the [MIT licence](LICENSE)
+submitting a change you agree it is licensed under the [MIT licence](../LICENSE)
like the rest of the project, and that you are entitled to submit it under that
licence. There is no CLA and no `Signed-off-by:` requirement — a plain
inbound = outbound statement is the whole of it.
@@ -34,17 +34,17 @@ inbound = outbound statement is the whole of it.
outside the queue until its branch is updated. `scripts/pr-keep-current.sh`
performs that update for every armed pull request (`--watch` repeats until
none is armed); run it after arming auto-merge, and after every merge that
- moves `main`. A pull request confined to `docs/`,
- `.abcd/development/`, `.abcd/work/` and the root prose files stands the macOS
- leg, the race lane and the `zizmor`, `govulncheck` and smoke lanes down while
- it is in review; the queue run is not a pull-request event, so the full set
- gates the merge either way.
+ moves `main`. A pull request confined to `docs/`, `.abcd/development/`,
+ `.abcd/work/`, the root prose files, and this guide and the security policy
+ beside it in `.github/` stands the macOS leg, the race lane and the `zizmor`,
+ `govulncheck` and smoke lanes down while it is in review; the queue run is not
+ a pull-request event, so the full set gates the merge either way.
- **Publish surface reviews.** Paths listed in
- [`.github/CODEOWNERS`](.github/CODEOWNERS) ship behaviour to installed users
+ [`.github/CODEOWNERS`](CODEOWNERS) ship behaviour to installed users
(plugin hooks and commands, agent prompts, workflows, gates and build
config). Changes there additionally require a code-owner review. The applied
branch rulesets are mirrored under
- [`.abcd/work/rulesets/`](.abcd/work/rulesets/).
+ [`.abcd/work/rulesets/`](../.abcd/work/rulesets/).
- **Volume cap.** At most three open pull requests per external author at a
time — review attention is the scarce resource this protects.
- **Local gates.** `make preflight` runs the load check first (load-check, a
@@ -56,7 +56,7 @@ inbound = outbound statement is the whole of it.
neither) — but not the format gate, so run `make fmt-check` before pushing
(it runs the gofmt from the toolchain `go.mod` declares, which is the one CI
runs; `make fmt` applies it). The repository
- ships its hooks in [`.githooks/`](.githooks/); they are per-machine opt-in —
+ ships its hooks in [`.githooks/`](../.githooks/); they are per-machine opt-in —
run `git config core.hooksPath .githooks` once per clone to arm the
pre-commit name guard, the commit-msg outbound check (it refuses a live
agent-session URL or a tool's attribution footer in a commit message, through
@@ -78,7 +78,7 @@ inbound = outbound statement is the whole of it.
that means `abcd spec close
` on every spec still open that names it.
- **Docs** are Diátaxis (one type per page, present tense); the design record lives
under `.abcd/`, never in `docs/`. Prose follows the canonical
- [writing style guide](docs/reference/writing-style.md).
+ [writing style guide](../docs/reference/writing-style.md).
- **New dependencies need explicit maintainer sign-off** before they land in
`go.mod`.
- **Run the plugin from your checkout.** The marketplace lists one plugin, and
@@ -157,7 +157,7 @@ a public issue for a security finding.
## Acknowledgements
-[`ACKNOWLEDGEMENTS.md`](ACKNOWLEDGEMENTS.md) credits the ideas, tools, and writing
+[`ACKNOWLEDGEMENTS.md`](../ACKNOWLEDGEMENTS.md) credits the ideas, tools, and writing
behind abcd in three parts — development, inspirations, and references. Add an entry
**in the same change that lands it**: the PR that adopts an external pattern, cites
a source in an ADR, or integrates a tool. Adding it at the moment it lands is what
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 4371f53f0..fa030649d 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -15,6 +15,6 @@
Assisted-by: None (no AI touched this change)
The gate refuses a missing or mid-sentence trailer, and refuses tool
- footers ("generated with ..."). See CONTRIBUTING.md § AI assistance. -->
+ footers ("generated with ..."). See .github/CONTRIBUTING.md § AI assistance. -->
Assisted-by:
diff --git a/SECURITY.md b/.github/SECURITY.md
similarity index 100%
rename from SECURITY.md
rename to .github/SECURITY.md
diff --git a/.github/workflows/attribution.yml b/.github/workflows/attribution.yml
index 541ea43d9..db03995fc 100644
--- a/.github/workflows/attribution.yml
+++ b/.github/workflows/attribution.yml
@@ -3,7 +3,7 @@ name: attribution
# Fails a pull request whose commits or body break abcd's AI-attribution
# convention: the kernel trailer `Assisted-by: Claude:`, never
# `Co-Authored-By:` for an AI, never a tool's own "Generated with " footer
-# (AGENTS.md § Attribution and acknowledgements, CONTRIBUTING.md).
+# (AGENTS.md § Attribution and acknowledgements, .github/CONTRIBUTING.md).
#
# It also refuses a LIVE AGENT-SESSION URL in any commit message in the range or
# in the pull-request body. That half was gated NOWHERE until it was added — not
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a96ff0aa9..90bcf4eb0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -136,12 +136,14 @@ jobs:
# The inert allowlist: user-facing documentation, the durable record,
# and the shared working tier, plus the root files that are prose or
- # licence text. The harness routers (AGENTS.md, CLAUDE.md, GEMINI.md)
- # are deliberately absent — they configure agent behaviour.
+ # licence text and the two community-health prose files in .github/.
+ # The harness routers (AGENTS.md, CLAUDE.md, GEMINI.md) are
+ # deliberately absent — they configure agent behaviour.
is_inert_path() {
case "$1" in
docs/*|.abcd/development/*|.abcd/work/*) return 0 ;;
- README.md|CHANGELOG.md|RELEASE.md|CONTRIBUTING.md|SECURITY.md|ACKNOWLEDGEMENTS.md|LICENSE|LICENSE.md) return 0 ;;
+ README.md|CHANGELOG.md|RELEASE.md|ACKNOWLEDGEMENTS.md|LICENSE|LICENSE.md) return 0 ;;
+ .github/CONTRIBUTING.md|.github/SECURITY.md) return 0 ;;
*) return 1 ;;
esac
}
diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md
index d4a621c98..c683b0e0b 100644
--- a/ACKNOWLEDGEMENTS.md
+++ b/ACKNOWLEDGEMENTS.md
@@ -13,7 +13,7 @@ they live in `go.mod` and the licence notices they carry.
Development of abcd has been assisted by Claude Code (Anthropic). Per-commit
disclosure uses an `Assisted-by:` trailer; the human contributor is the author of
record and is responsible for all AI-assisted output — its correctness, licensing,
-and fit for the project. See [`CONTRIBUTING.md`](CONTRIBUTING.md).
+and fit for the project. See [`CONTRIBUTING.md`](.github/CONTRIBUTING.md).
External reports sharpen the record, and fix commits credit their reporters with
a `Reported-by:` trailer. [Andy Woods (@andytwoods)](https://github.com/andytwoods)
diff --git a/AGENTS.md b/AGENTS.md
index 7f8cd73db..5c1a4340c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -162,9 +162,10 @@ site-render gate on the Linux leg alone. Separate jobs run the reviews-charter c
(`make smoke`). A
fail-closed classifier stands the macOS leg, the race lane and the `zizmor`,
`govulncheck` and smoke jobs down on a pull request confined to `docs/`,
-`.abcd/development/`, `.abcd/work/` and the root prose files; the Linux unit
-lane, the format gate and the record gates always run, and every other event —
-the merge-queue entry that gates the merge included — runs the lot.
+`.abcd/development/`, `.abcd/work/`, the root prose files and the
+community-health files in `.github/`; the Linux unit lane, the format gate and
+the record gates always run, and every other event — the merge-queue entry that
+gates the merge included — runs the lot.
## Working-tree layout (three tiers under `.abcd/`)
@@ -390,7 +391,7 @@ irreversible; guessing downward costs nothing.**
`Co-Authored-By:` for AI (it asserts an authorship the tool does not hold and
inflates the contributor graph). There is no DCO: contributions are inbound =
outbound MIT, so no `Signed-off-by:` is required (adr-43). The human is the
- author of record, responsible for all AI-assisted output. See `CONTRIBUTING.md`.
+ author of record, responsible for all AI-assisted output. See `.github/CONTRIBUTING.md`.
- **Every commit is authored by a human, and the gate refuses a machine.** The
contributor graph is built from the author and committer fields, so a machine
there asserts an authorship it does not hold — and a squash merge re-appends a
diff --git a/README.md b/README.md
index 70a9fc4cd..7d44c5272 100644
--- a/README.md
+++ b/README.md
@@ -152,5 +152,5 @@ repository* button reads:
## Resources
- [`LICENSE`](LICENSE): MIT.
-- [`SECURITY.md`](SECURITY.md): Report a vulnerability privately.
+- [`SECURITY.md`](.github/SECURITY.md): Report a vulnerability privately.
- [`ACKNOWLEDGEMENTS.md`](ACKNOWLEDGEMENTS.md): The ideas, tools, and writing `abcd` stands on.
diff --git a/commands/site.md b/commands/site.md
index 02fd550fe..3b9093b62 100644
--- a/commands/site.md
+++ b/commands/site.md
@@ -50,11 +50,12 @@ reads exactly this set — `.abcd/site.json`, `site-src/ui.json`,
`.abcd/development/` and the opted-in issue ledger, git history,
`CHANGELOG.md`, the composed pages and assets under `docs/`, the static inputs
`site-src/{site.css,site.js,record.js,redirects,headers}` and the served
-`site-src/install.sh.tmpl`, the credit sources `CONTRIBUTING.md` and
-`ACKNOWLEDGEMENTS.md` (and the existence of `SECURITY.md` and `CITATION.cff`
-for the footer), `.abcd/site-baseline.json` (the ratchet the health block
-counts against), and `.claude-plugin/plugin.json` (the forge URL, licence and
-author the links and footer use) — and writes the landing page, the record
+`site-src/install.sh.tmpl`, the credit sources `.github/CONTRIBUTING.md` and
+`ACKNOWLEDGEMENTS.md` (and the existence of `SECURITY.md` — in `.github/`, at
+the root or in `docs/` — and `CITATION.cff` for the footer),
+`.abcd/site-baseline.json` (the ratchet the health block counts against), and
+`.claude-plugin/plugin.json` (the forge URL, licence and author the links and
+footer use) — and writes the landing page, the record
export, the redirect and header maps, the stylesheet, the two scripts, the
`install.sh`, and every referenced raster into the output directory, and
nowhere else. It reaches no network. The default output directory is `site`,
diff --git a/internal/core/lint/preflightgates_test.go b/internal/core/lint/preflightgates_test.go
index 536128f90..31214c47f 100644
--- a/internal/core/lint/preflightgates_test.go
+++ b/internal/core/lint/preflightgates_test.go
@@ -51,12 +51,12 @@ func TestPreflightGateListIsNotRestatedWrongly(t *testing.T) {
// Every surface that enumerates the gates. A file joins this list when it
// starts restating them — which is the moment it becomes able to drift.
for _, rel := range []string{
- "Makefile", // the recipe's own comment, above the recipe
- "docs/how-to/install.md", // the build section a contributor copies
- "CONTRIBUTING.md", // the local-gates paragraph
- "AGENTS.md", // the definition-of-done list
- "CLAUDE.md", // AGENTS.md's committed mirror
- ".githooks/pre-push", // the hook that invokes the recipe
+ "Makefile", // the recipe's own comment, above the recipe
+ "docs/how-to/install.md", // the build section a contributor copies
+ ".github/CONTRIBUTING.md", // the local-gates paragraph
+ "AGENTS.md", // the definition-of-done list
+ "CLAUDE.md", // AGENTS.md's committed mirror
+ ".githooks/pre-push", // the hook that invokes the recipe
} {
t.Run(rel, func(t *testing.T) {
prose := readRepoFile(t, root, rel)
@@ -485,7 +485,7 @@ func TestFormatGateResolvesThroughTheDeclaredToolchain(t *testing.T) {
for _, rel := range []string{
"AGENTS.md", // the command table and the definition-of-done list
"CLAUDE.md", // AGENTS.md's committed mirror
- "CONTRIBUTING.md", // the local-gates paragraph
+ ".github/CONTRIBUTING.md", // the local-gates paragraph
".github/PULL_REQUEST_TEMPLATE.md", // the verification prompt
".githooks/pre-push", // the hook's header, which tells the developer what CI adds
} {
diff --git a/scripts/check-attribution.sh b/scripts/check-attribution.sh
index 9f9768331..bae58f3f2 100755
--- a/scripts/check-attribution.sh
+++ b/scripts/check-attribution.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Deterministic gate for abcd's AI-attribution convention (AGENTS.md § Attribution,
-# CONTRIBUTING.md): an AI-assisted commit message and an AI-assisted pull-request
+# .github/CONTRIBUTING.md): an AI-assisted commit message and an AI-assisted pull-request
# body each carry the kernel-format trailer
#
# Assisted-by: Claude:
@@ -13,7 +13,7 @@
# scanner.CheckOutbound. See outbound_checker() below for why it cannot be a regex
# here, and why delegating is not the usual shell-calls-Go mistake.
#
-# Stopgap: the convention has lived as prose in AGENTS.md and CONTRIBUTING.md
+# Stopgap: the convention has lived as prose in AGENTS.md and .github/CONTRIBUTING.md
# since the beginning and drifted anyway — itd-91 records a reconciliation sweep
# across 78 pull requests after PR bodies picked up a tool's default footer. Prose
# is not the missing piece; a check that fails closed is. itd-91 owns the general
From 085ad84d5b5d59af90614768ee7ad984f81f1565 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:06:05 +0100
Subject: [PATCH 11/84] =?UTF-8?q?chore:=20resolve=20iss-2608270540523859?=
=?UTF-8?q?=20=E2=80=94=20the=20community-health=20files=20live=20in=20.gi?=
=?UTF-8?q?thub/?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves: iss-2608270540523859
Assisted-by: Claude:claude-opus-5-5
---
...uting-md-and-security-md-from-repo-root-to-githu.md | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
rename .abcd/work/issues/{open => resolved}/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md (53%)
diff --git a/.abcd/work/issues/open/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md b/.abcd/work/issues/resolved/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md
similarity index 53%
rename from .abcd/work/issues/open/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md
rename to .abcd/work/issues/resolved/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md
index f54a8eb93..9b09057bf 100644
--- a/.abcd/work/issues/open/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md
+++ b/.abcd/work/issues/resolved/iss-2608270540523859-move-contributing-md-and-security-md-from-repo-root-to-githu.md
@@ -7,6 +7,14 @@ category: "tech-debt"
source: "user-observation"
found_during: "config-placement-reorg-2026-08-27"
found_at: "internal/core/site/compose.go"
+resolution: "CONTRIBUTING.md and SECURITY.md live in .github/ (4d4bbfb3), with every reader repointed in the same change: the site manifest's policy source, the stray_root_docs allowlist, README/ACKNOWLEDGEMENTS/AGENTS links, CI's inert-path classifier, the gate-list tests, the tooling comments, the site command page and the brief. The site and the lifeboat probe read the community-health files from .github/ as the forge does (5b56c19d): the footer resolves SECURITY.md across .github/, root and docs/, the policy source admits markdown directly in .github/, the conventions tier counts .github/CONTRIBUTING.md. The launch payload never carried either file. GitHub recognises .github/ for both per its community-health documentation; not re-checked against the live forge."
+impact: additive
+resolved_by:
+ commit: "4d4bbfb3"
---
-Move CONTRIBUTING.md and SECURITY.md from repo root to .github/ to de-clutter root. NOT a plain git mv: both are load-bearing inputs to abcd's own site build. Required coupled changes: (1) internal/core/site/compose.go footer builds 'blob/main/SECURITY.md' links from a hardcoded root-relative list — update it or the security link silently drops; (2) the contributors/attribution page is config-driven from CONTRIBUTING.md via record_pages.contributors.policy.file (.abcd/config/site or equivalent) — repoint to .github/CONTRIBUTING.md; (3) remove CONTRIBUTING/SECURITY stems from the stray_root_docs allowlist (internal/core/lint/config.go); (4) fix ~6 relative links (README.md, ACKNOWLEDGEMENTS.md, AGENTS.md, CONTRIBUTING.md->SECURITY.md, and site markdown/explorer tests pinning ../../CONTRIBUTING.md and CONTRIBUTING.md#attribution); (5) internal/core/lifeboat/probe.go known-files list names CONTRIBUTING.md; (6) confirm GitHub still surfaces both community-health files from .github/ (it recognises root, docs/, and .github/). Verify with site-render + docs-lint gates. Related to the root-layout / config-placement ADR discussion and the governance/ mirror rename.
\ No newline at end of file
+Move CONTRIBUTING.md and SECURITY.md from repo root to .github/ to de-clutter root. NOT a plain git mv: both are load-bearing inputs to abcd's own site build. Required coupled changes: (1) internal/core/site/compose.go footer builds 'blob/main/SECURITY.md' links from a hardcoded root-relative list — update it or the security link silently drops; (2) the contributors/attribution page is config-driven from CONTRIBUTING.md via record_pages.contributors.policy.file (.abcd/config/site or equivalent) — repoint to .github/CONTRIBUTING.md; (3) remove CONTRIBUTING/SECURITY stems from the stray_root_docs allowlist (internal/core/lint/config.go); (4) fix ~6 relative links (README.md, ACKNOWLEDGEMENTS.md, AGENTS.md, CONTRIBUTING.md->SECURITY.md, and site markdown/explorer tests pinning ../../CONTRIBUTING.md and CONTRIBUTING.md#attribution); (5) internal/core/lifeboat/probe.go known-files list names CONTRIBUTING.md; (6) confirm GitHub still surfaces both community-health files from .github/ (it recognises root, docs/, and .github/). Verify with site-render + docs-lint gates. Related to the root-layout / config-placement ADR discussion and the governance/ mirror rename.
+
+## Grounds
+
+- pursued: the site still publishes the contributors policy quote and the footer security link from .github/ and passes its gates (make site-render; TestTheSiteReadsCommunityHealthFilesFromTheGithubDirectory, TestTheProvenanceGateReadsTheFooterFileItResolved), and docs-lint links_resolve passes; a dropped footer link, a policy refusal or a dangling README link would show it wrong
From 6c9718e493ebec015dea6320669eb782d181c9ca Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:50:11 +0100
Subject: [PATCH 12/84] chore(capture): the board, memory lint and launch
archive report absolute paths
Three more --json fields carry an absolute developer-identity path, against
the iss-81 rule: the bare board's dir, memory lint's report_dir and
store_path, and launch archive's archive.path. The first two were named in
the drainSite review; the third was found in the sweep of every path-bearing
--json field.
Refs: iss-2609261950066257, iss-2609261950061900, iss-2609261950077063, iss-81
Assisted-by: Claude:claude-opus-5-5
---
...ports-its-run-log-directory-and-the-store-it.md | 14 ++++++++++++++
...reports-its-directory-as-an-absolute-path-in.md | 14 ++++++++++++++
...hive-json-reports-the-archive-it-wrote-as-an.md | 14 ++++++++++++++
3 files changed, 42 insertions(+)
create mode 100644 .abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
create mode 100644 .abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
create mode 100644 .abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
diff --git a/.abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md b/.abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
new file mode 100644
index 000000000..f70b4e3a4
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
@@ -0,0 +1,14 @@
+---
+schema_version: 1
+id: "iss-2609261950061900"
+slug: "memory-lint-reports-its-run-log-directory-and-the-store-it"
+severity: "minor"
+category: "security"
+source: "review-followup"
+found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/memory/lint.go"
+---
+
+memory lint reports its run-log directory and the store it read as absolute paths: LintResult.ReportDir and LintResult.StorePath (internal/core/memory/lint.go) are joined onto the repository root, so memory lint --json carries report_dir and store_path naming the developer's home whenever the checkout sits under it, against the iss-81 rule; the text report prints ReportDir too.
diff --git a/.abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md b/.abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
new file mode 100644
index 000000000..5cae4cd2f
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
@@ -0,0 +1,14 @@
+---
+schema_version: 1
+id: "iss-2609261950066257"
+slug: "the-bare-board-reports-its-directory-as-an-absolute-path-in"
+severity: "minor"
+category: "security"
+source: "review-followup"
+found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/core.go"
+---
+
+The bare board reports its directory as an absolute path in machine output: abcd --json carries dir as filepath.Abs of the working directory (core.Status, internal/core/core.go, embedded in the board envelope by internal/surface/cli/cli.go), so a checkout under the home names the developer in --json, against the iss-81 rule the site and lifeboat verbs are held to. The text board prints the same field on its first line.
diff --git a/.abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md b/.abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
new file mode 100644
index 000000000..f8ab23660
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
@@ -0,0 +1,14 @@
+---
+schema_version: 1
+id: "iss-2609261950077063"
+slug: "launch-archive-json-reports-the-archive-it-wrote-as-an"
+severity: "minor"
+category: "security"
+source: "review-followup"
+found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/launch/archive.go"
+---
+
+launch archive --json reports the archive it wrote as an absolute path: PluginArchive.Path (internal/core/launch/archive.go) is the --out directory made absolute by the front door and joined with the archive name, so archive.path names the developer's home whenever --out sits under it, against the iss-81 rule; the text report's written line prints the same value. Found in the drainPaths sweep of path-bearing --json fields.
From a321dffe3484bc03c92ac404b23feff4cdd579c2 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:54:31 +0100
Subject: [PATCH 13/84] chore(capture): the launch bundle names every payload
file absolutely
Found in the sweep by running the read-only --json verbs from a checkout
under the home: every bundle file's resolved_path in launch --dry-run --json
and in launch ship's payload.bundle is the file's absolute on-disk path.
Refs: iss-2609261954288630, iss-81
Assisted-by: Claude:claude-opus-5-5
---
...json-and-launch-ship-json-name-every-payload.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
create mode 100644 .abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
diff --git a/.abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md b/.abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
new file mode 100644
index 000000000..6e616c922
--- /dev/null
+++ b/.abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
@@ -0,0 +1,14 @@
+---
+schema_version: 1
+id: "iss-2609261954288630"
+slug: "launch-dry-run-json-and-launch-ship-json-name-every-payload"
+severity: "minor"
+category: "security"
+source: "review-followup"
+found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/launch/bundle.go"
+---
+
+launch --dry-run --json and launch ship --json name every payload file absolutely: IncludedFile.ResolvedPath (internal/core/launch/bundle.go) is the file's absolute on-disk path and is tagged resolved_path, so the bundle's files list in the dry-run report and in a ship's payload.bundle carries the checkout's absolute path once per file, naming the developer's home whenever the checkout sits under it, against the iss-81 rule. Found in the drainPaths sweep by running the read-only --json verbs from a checkout under the home.
From 693e1a771f6d475ca13d2dd025078ce3ebe84245 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:57:29 +0100
Subject: [PATCH 14/84] refactor(fsutil): promote the site display rule to
fsutil.DisplayPath
The site verbs' displayOutDir is the rule every report that names a path
needs: inside the repository, relative to it; outside it, the home redacted
to "~"; a relative path, as given. It moves to fsutil as DisplayPath so the
launch, memory and board fixes that follow share one primitive, and the site
verbs call it directly.
It also judges "inside" over the real locations when the lexical test says
outside, so a path the kernel resolved (/private/var for /var) still reads as
inside a repository root spelled the other way.
Refs: iss-81
Assisted-by: Claude:claude-opus-5-5
---
internal/core/site/build.go | 6 ++--
internal/core/site/check.go | 2 +-
internal/core/site/outdir.go | 15 ---------
internal/fsutil/displaypath_test.go | 47 +++++++++++++++++++++++++++++
internal/fsutil/paths.go | 35 +++++++++++++++++++++
5 files changed, 86 insertions(+), 19 deletions(-)
create mode 100644 internal/fsutil/displaypath_test.go
diff --git a/internal/core/site/build.go b/internal/core/site/build.go
index de902a059..6750a9d2a 100644
--- a/internal/core/site/build.go
+++ b/internal/core/site/build.go
@@ -465,7 +465,7 @@ func Build(req Request) (Result, error) {
}
res := Result{
- OutDir: displayOutDir(repoRoot, outDir),
+ OutDir: fsutil.DisplayPath(repoRoot, outDir),
Records: len(export.Nodes),
Links: len(export.Edges),
Mentions: len(export.Mentions),
@@ -606,7 +606,7 @@ func Describe(repoRoot, outDir string) (Status, error) {
if outDir == "" {
outDir = DefaultOutDir
}
- st := Status{OutDir: displayOutDir(repoRoot, outDir), Commit: HeadCommit(repoRoot), BaselinePath: BaselineRelPath}
+ st := Status{OutDir: fsutil.DisplayPath(repoRoot, outDir), Commit: HeadCommit(repoRoot), BaselinePath: BaselineRelPath}
baselineRel := ""
m, err := LoadManifest(repoRoot)
switch {
@@ -660,7 +660,7 @@ func Describe(repoRoot, outDir string) (Status, error) {
if gerr != nil {
// Redacted at the source, so the text board and --json agree on this
// field: OutRefused never carries an absolute developer-identity path
- // (iss-81); OutDir reaches the same end through displayOutDir.
+ // (iss-81); OutDir reaches the same end through fsutil.DisplayPath.
st.OutRefused = fsutil.RedactHome(fsutil.RedactRoot(gerr.Error(), repoRoot, "."))
return st, nil
}
diff --git a/internal/core/site/check.go b/internal/core/site/check.go
index 5df4158ab..d27879117 100644
--- a/internal/core/site/check.go
+++ b/internal/core/site/check.go
@@ -275,7 +275,7 @@ func Check(req CheckRequest) (CheckResult, error) {
// surface, and a reader that has to tell `null` from `[]` is a reader that
// will one day get it wrong.
res := CheckResult{
- OutDir: displayOutDir(repoRoot, outDir), Checks: CheckNames,
+ OutDir: fsutil.DisplayPath(repoRoot, outDir), Checks: CheckNames,
Pages: []string{}, Composed: []string{},
Findings: []CheckFinding{}, Notes: []CheckFinding{},
}
diff --git a/internal/core/site/outdir.go b/internal/core/site/outdir.go
index 7214c9d1c..bd840fb76 100644
--- a/internal/core/site/outdir.go
+++ b/internal/core/site/outdir.go
@@ -321,18 +321,3 @@ func refuseTrackedOutDir(outDir string) error {
}
return nil
}
-
-// displayOutDir is the output directory as the verbs report it. The report
-// travels into --json, and machine output never carries an absolute
-// developer-identity path (iss-81): a directory inside the repository is named
-// relative to it, and one outside it has the home directory redacted to "~". A
-// relative path is reported as it was given (iss-2608291957114882).
-func displayOutDir(repoRoot, outDir string) string {
- if !filepath.IsAbs(outDir) {
- return outDir
- }
- if rel, err := filepath.Rel(repoRoot, outDir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
- return filepath.ToSlash(fsutil.RepoRel(repoRoot, outDir))
- }
- return fsutil.RedactHome(outDir)
-}
diff --git a/internal/fsutil/displaypath_test.go b/internal/fsutil/displaypath_test.go
new file mode 100644
index 000000000..25d562b7a
--- /dev/null
+++ b/internal/fsutil/displaypath_test.go
@@ -0,0 +1,47 @@
+package fsutil_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
+)
+
+// DisplayPath is the one rendering of a path that travels into machine output,
+// which never carries an absolute developer-identity path (iss-81): a path
+// inside the repository is named relative to it, one outside it has the home
+// directory redacted to "~", and a relative path is reported as it was given.
+func TestDisplayPathNamesAPathWithoutTheHome(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ repo := filepath.Join(home, "src", "repo")
+ if err := os.MkdirAll(repo, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ elsewhere := t.TempDir()
+
+ for name, tc := range map[string]struct{ in, want string }{
+ "inside the repository": {filepath.Join(repo, "public", "a.zip"), "public/a.zip"},
+ "the repository root": {repo, "."},
+ "outside it, under the home": {filepath.Join(home, "staging"), "~/staging"},
+ "a sibling sharing a prefix": {repo + "-other", "~/src/repo-other"},
+ "outside it and the home": {filepath.Join(elsewhere, "out"), filepath.Join(elsewhere, "out")},
+ "relative, reported as given": {"site", "site"},
+ "empty, reported as given": {"", ""},
+ "inside, through the real home": {filepath.Join(realPath(t, home), "src", "repo", "bin"), "bin"},
+ } {
+ if got := fsutil.DisplayPath(repo, tc.in); got != tc.want {
+ t.Errorf("%s: DisplayPath(%q) = %q, want %q", name, tc.in, got, tc.want)
+ }
+ }
+}
+
+func realPath(t *testing.T, p string) string {
+ t.Helper()
+ r, err := filepath.EvalSymlinks(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
diff --git a/internal/fsutil/paths.go b/internal/fsutil/paths.go
index a8e450001..21145a062 100644
--- a/internal/fsutil/paths.go
+++ b/internal/fsutil/paths.go
@@ -312,6 +312,41 @@ func RedactHome(s string) string {
return s
}
+// DisplayPath is a path as a report names it. A report travels into --json, and
+// machine output never carries an absolute developer-identity path (iss-81): a
+// path inside repoRoot is named relative to it, slash-separated; one outside it
+// has the home directory redacted to "~" (RedactHome); a relative path is
+// reported as it was given. It is for display only — a caller that acts on the
+// path keeps the working value beside it.
+//
+// Inside is judged lexically first and then over the real locations, so a path
+// the kernel resolved (macOS's /private/var for /var) still reads as inside a
+// repoRoot spelled the other way.
+func DisplayPath(repoRoot, p string) string {
+ if !filepath.IsAbs(p) {
+ return p
+ }
+ if rel, ok := relInside(repoRoot, p); ok {
+ return filepath.ToSlash(rel)
+ }
+ if rel, ok := relInside(RealExistingPath(repoRoot), RealExistingPath(p)); ok {
+ return filepath.ToSlash(rel)
+ }
+ return RedactHome(p)
+}
+
+// relInside is p relative to root when p is root or lies under it.
+func relInside(root, p string) (string, bool) {
+ if root == "" || p == "" {
+ return "", false
+ }
+ rel, err := filepath.Rel(root, p)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return "", false
+ }
+ return rel, true
+}
+
// 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 {
From ae58e92603b8fcc94fff52233d3d590ade7614fc Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:58:06 +0100
Subject: [PATCH 15/84] fix(launch): report the payload, archive and bundle
paths without the home
Three launch reports named absolute paths in --json, against the iss-81 rule:
launch ship's payload.dest (the resolved staging directory), launch archive's
archive.path (the --out directory made absolute, joined with the archive
name), and every bundle file's resolved_path in launch --dry-run and in a
ship's payload.bundle.
Each field was also the working value something reads back: the archive step
packs from the render's Dest, the archive verb removes a refused archive
through its Path, and the render, the gates, the scan and the parity diff open
every payload file through ResolvedPath. So the working value is not redacted
in place. Each struct keeps it under its Go name, now tagged json:"-", and
gains a display-only field that carries the unchanged JSON key, set once
through fsutil.DisplayPath where the value is made: payload.dest with the home
as "~" (a destination is always outside the repository), archive.path relative
to the repository for the release workflow's --out bin and with the home as
"~" otherwise, and resolved_path relative to the repository. The text reports
print the display fields, so the two renderings agree.
Refs: iss-2609261848338673, iss-2609261950077063, iss-2609261954288630, iss-81
Assisted-by: Claude:claude-opus-5-5
---
commands/launch.md | 8 +-
internal/core/launch/archive.go | 11 +-
internal/core/launch/bundle.go | 29 ++++-
internal/core/launch/render.go | 11 +-
internal/core/launch/reportpaths_test.go | 144 +++++++++++++++++++++++
internal/surface/cli/archive.go | 2 +-
internal/surface/cli/ship.go | 2 +-
7 files changed, 193 insertions(+), 14 deletions(-)
create mode 100644 internal/core/launch/reportpaths_test.go
diff --git a/commands/launch.md b/commands/launch.md
index 8675885c7..89e86a0aa 100644
--- a/commands/launch.md
+++ b/commands/launch.md
@@ -189,6 +189,7 @@ Then summarise the JSON for the user:
- `version` — the version the release would carry.
- `bundle.files` — the files the bundle would include (an array; report its length as the count).
+ Each entry names its file relative to the repository, `resolved_path` included.
- `scan.hard_fails` — secret/PII findings that would block the release.
`scan.findings` keeps at most 10,000 of them; `scan.findings_omitted`, when
present, counts the rest, and `scan.hard_fails` counts every one.
@@ -494,7 +495,8 @@ stamped into the payload's copies of `plugin.json` and `marketplace.json`. The
repository's own manifests are never touched: they carry no version, and the
version belongs to the artefact. The staged payload is proved consistent before
the command returns, so a stamp that missed a pinned location is a refusal rather
-than a published half-state. Every refusal the staging step can make is checked
+than a published half-state. The report's `payload.dest` names that directory
+with the home directory written as `~`. Every refusal the staging step can make is checked
BEFORE the dated heading is written, and a refusal that slips past that check
rolls the heading back — so a ship that exits non-zero leaves no release record
behind for the next attempt to trip over. Without the flag nothing is staged;
@@ -730,7 +732,9 @@ heading names, from the checked-out tree, and writes
`-plugin-vX.Y.Z.zip` into an existing directory. The archive is
reproducible — sorted entries, stored uncompressed, one fixed timestamp, modes
normalised — so the same commit renders the same bytes on any machine. The
-catalog is left out of it, because the catalog is what names its digest.
+catalog is left out of it, because the catalog is what names its digest. The
+report's `archive.path` names the written archive relative to the repository
+when `--out` is inside it, and with the home directory as `~` otherwise.
```bash
"${CLAUDE_PLUGIN_ROOT}/abcd" launch archive --out [--tag vX.Y.Z] [--verify] [--repository ] --json
diff --git a/internal/core/launch/archive.go b/internal/core/launch/archive.go
index af4287d4d..91578db6f 100644
--- a/internal/core/launch/archive.go
+++ b/internal/core/launch/archive.go
@@ -84,8 +84,14 @@ var githubRepositoryRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-
type PluginArchive struct {
// Name is the release asset's file name, -plugin-v.zip.
Name string `json:"name"`
- // Path is where the archive was written.
- Path string `json:"path"`
+ // Path is where the archive was written: the absolute working value a
+ // caller removes or reads the archive through. It never reaches machine
+ // output (iss-81); DisplayPath is what a report names.
+ Path string `json:"-"`
+ // DisplayPath is Path as a report names it (fsutil.DisplayPath): relative
+ // to the repository when --out is inside it, the home redacted to "~"
+ // otherwise (iss-2609261950077063).
+ DisplayPath string `json:"path"`
// SHA256 is the lower-case hex digest of the archive's bytes.
SHA256 string `json:"sha256"`
// Version is the release version stamped into the archived plugin manifest.
@@ -134,6 +140,7 @@ func RenderPluginArchive(req PayloadRenderRequest, outDir string) (PluginArchive
a.Name = PluginArchiveName(name, req.Version)
a.Version = req.Version
a.Path = filepath.Join(outDir, a.Name)
+ a.DisplayPath = fsutil.DisplayPath(req.RepoRoot, a.Path)
if _, err := os.Lstat(a.Path); err == nil {
return a, res, fmt.Errorf("%s already exists in the output directory — refusing to replace a release archive", a.Name)
}
diff --git a/internal/core/launch/bundle.go b/internal/core/launch/bundle.go
index 81789759b..dfd79d5b0 100644
--- a/internal/core/launch/bundle.go
+++ b/internal/core/launch/bundle.go
@@ -22,6 +22,7 @@ import (
"sync"
"syscall"
+ "github.com/intentdriven/abcd/internal/fsutil"
"github.com/intentdriven/abcd/internal/gitutil"
)
@@ -61,11 +62,15 @@ const (
)
// IncludedFile is a resolved payload file. Paths are repo-relative POSIX;
-// ResolvedPath is the absolute on-disk (dereferenced) path.
+// ResolvedPath is the absolute on-disk (dereferenced) path every reader opens
+// the file through, and it never reaches machine output (iss-81):
+// DisplayResolvedPath is the same file named relative to the repository, which
+// is what a report carries as resolved_path (iss-2609261954288630).
type IncludedFile struct {
- LogicalPath string `json:"logical_path"`
- ResolvedPath string `json:"resolved_path"`
- GitMode string `json:"git_mode"` // "100644" | "100755"
+ LogicalPath string `json:"logical_path"`
+ ResolvedPath string `json:"-"`
+ DisplayResolvedPath string `json:"resolved_path"`
+ GitMode string `json:"git_mode"` // "100644" | "100755"
}
// ExcludedFile is a benign exclusion.
@@ -318,6 +323,18 @@ func (r *resolver) classifyRegular(rel, abs string, info os.FileInfo, deref bool
})
}
+// included is the Included entry for one surviving candidate: the working
+// absolute path every reader opens, and the same file named relative to the
+// repository for the report.
+func (r *resolver) included(c candidate) IncludedFile {
+ return IncludedFile{
+ LogicalPath: c.logical,
+ ResolvedPath: c.resolved,
+ DisplayResolvedPath: fsutil.DisplayPath(r.root, c.resolved),
+ GitMode: c.gitMode,
+ }
+}
+
// handleSymlink resolves a symlink structurally (escape/cycle/deny) and, when
// accepted, dereferences it: a file is classified under its logical path; a
// directory is walked with its contents emitted under the symlink's prefix. A
@@ -498,7 +515,7 @@ func (r *resolver) finalize() {
group := byLogical[logical]
if len(group) == 1 {
c := group[0]
- r.result.Included = append(r.result.Included, IncludedFile{LogicalPath: c.logical, ResolvedPath: c.resolved, GitMode: c.gitMode})
+ r.result.Included = append(r.result.Included, r.included(c))
continue
}
// Same logical path from multiple sources: same inode → dedup with a
@@ -513,7 +530,7 @@ func (r *resolver) finalize() {
}
if sameInode {
r.result.Warnings = append(r.result.Warnings, "duplicate provenance for "+logical+" (same inode); kept one")
- r.result.Included = append(r.result.Included, IncludedFile{LogicalPath: first.logical, ResolvedPath: first.resolved, GitMode: first.gitMode})
+ r.result.Included = append(r.result.Included, r.included(first))
} else {
r.result.Rejected = append(r.result.Rejected, RejectedFile{LogicalPath: logical, Reason: RejectedDuplicate})
}
diff --git a/internal/core/launch/render.go b/internal/core/launch/render.go
index 37231aecb..c50c91685 100644
--- a/internal/core/launch/render.go
+++ b/internal/core/launch/render.go
@@ -83,8 +83,14 @@ var renderPathDocAudit = &DocAuditPreflight{
// PayloadRenderResult is a completed render.
type PayloadRenderResult struct {
- // Dest is the staging directory the payload was written to.
- Dest string `json:"dest"`
+ // Dest is the staging directory the payload was written to: the resolved,
+ // absolute working value the archive step packs from. It never reaches
+ // machine output (iss-81); DisplayDest is what a report names.
+ Dest string `json:"-"`
+ // DisplayDest is Dest as a report names it (fsutil.DisplayPath): relative
+ // to the repository inside it, the home redacted to "~" outside it — and a
+ // destination is always outside it (iss-2609261848338673).
+ DisplayDest string `json:"dest"`
// Version is the version stamped at every pinned location.
Version string `json:"version"`
// Bundle is the resolution the payload was written from, so a caller can
@@ -420,6 +426,7 @@ func RenderPayload(req PayloadRenderRequest) (PayloadRenderResult, error) {
primaryPath, primaryPtr := pre.PrimaryPath, pre.PrimaryPointer
bundle := pre.Bundle
res.Dest = dest
+ res.DisplayDest = fsutil.DisplayPath(req.RepoRoot, dest)
res.Bundle = bundle
if err := os.MkdirAll(dest, 0o755); err != nil {
diff --git a/internal/core/launch/reportpaths_test.go b/internal/core/launch/reportpaths_test.go
new file mode 100644
index 000000000..4e8707d90
--- /dev/null
+++ b/internal/core/launch/reportpaths_test.go
@@ -0,0 +1,144 @@
+package launch
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// noHomeIn fails when raw names the home directory in either spelling — the
+// one the environment gives and the symlink-resolved one the kernel reports —
+// which is the developer-identity path machine output never carries (iss-81).
+func noHomeIn(t *testing.T, what string, raw []byte, home string) {
+ t.Helper()
+ spellings := []string{home}
+ if real, err := filepath.EvalSymlinks(home); err == nil && real != home {
+ spellings = append(spellings, real)
+ }
+ for _, h := range spellings {
+ if strings.Contains(string(raw), h) {
+ t.Errorf("%s carries the home directory %q:\n%s", what, h, raw)
+ }
+ }
+}
+
+// homeFixture is renderFixture laid down under a fresh home directory, so every
+// absolute path the render and the archive work with sits under $HOME — the
+// shape that named the developer in `launch ship --json` and
+// `launch archive --json`.
+func homeFixture(t *testing.T) (home, root string) {
+ t.Helper()
+ home = t.TempDir()
+ t.Setenv("HOME", home)
+ root = filepath.Join(home, "src", "repo")
+ writeFile(t, root, ".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "README.md"]}`)
+ writeFile(t, root, "README.md", "readme\n")
+ writeLockstepTree(t, root, "", "", "")
+ writeFile(t, root, ".claude-plugin/plugin.json",
+ `{"name": "abcd", "repository": "https://github.com/example/abcd"}`)
+ return home, root
+}
+
+// The payload's destination is reported without the home path: payload.dest
+// names the staging directory with the home redacted to "~", and every bundle
+// file's resolved_path is repository-relative — while the render still writes
+// to, and the archive still packs from, the real directory
+// (iss-2609261848338673, iss-2609261950077063).
+func TestTheRenderAndTheArchiveReportTheirPathsWithoutTheHome(t *testing.T) {
+ home, root := homeFixture(t)
+ out := filepath.Join(home, "dist")
+ if err := os.MkdirAll(out, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ a, res, err := RenderPluginArchive(PayloadRenderRequest{
+ RepoRoot: root, Dest: filepath.Join(home, "staging"), Version: "1.2.3", Entry: sampleEntry(), Dirty: DirtySkip,
+ }, out)
+ if err != nil {
+ t.Fatalf("RenderPluginArchive: %v", err)
+ }
+
+ rawRes, err := json.Marshal(res)
+ if err != nil {
+ t.Fatal(err)
+ }
+ noHomeIn(t, "the render's JSON", rawRes, home)
+ var gotRes struct {
+ Dest string `json:"dest"`
+ Bundle struct {
+ Files []struct {
+ LogicalPath string `json:"logical_path"`
+ ResolvedPath string `json:"resolved_path"`
+ } `json:"files"`
+ } `json:"bundle"`
+ }
+ if err := json.Unmarshal(rawRes, &gotRes); err != nil {
+ t.Fatal(err)
+ }
+ if gotRes.Dest != "~/staging" {
+ t.Errorf("payload.dest = %q, want ~/staging", gotRes.Dest)
+ }
+ if len(gotRes.Bundle.Files) == 0 {
+ t.Fatal("the render reported no bundle files")
+ }
+ for _, f := range gotRes.Bundle.Files {
+ if f.ResolvedPath != f.LogicalPath {
+ t.Errorf("bundle file %q: resolved_path = %q, want it repository-relative", f.LogicalPath, f.ResolvedPath)
+ }
+ }
+
+ rawArchive, err := json.Marshal(a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ noHomeIn(t, "the archive's JSON", rawArchive, home)
+ var gotArchive struct {
+ Path string `json:"path"`
+ }
+ if err := json.Unmarshal(rawArchive, &gotArchive); err != nil {
+ t.Fatal(err)
+ }
+ if want := "~/dist/" + a.Name; gotArchive.Path != want {
+ t.Errorf("archive.path = %q, want %q", gotArchive.Path, want)
+ }
+
+ // The working values still reach the directories: the payload is on disk
+ // where the render put it, and the archive opens from where it was written.
+ if _, err := os.Stat(filepath.Join(home, "staging", ".claude-plugin", "plugin.json")); err != nil {
+ t.Errorf("the staged payload is not where the render was told to write it: %v", err)
+ }
+ if entries := zipEntries(t, filepath.Join(out, a.Name)); len(entries) == 0 {
+ t.Error("the archive written to --out is empty")
+ }
+}
+
+// An archive written inside the repository — the release workflow's
+// `--out bin` — is reported relative to it.
+func TestAnArchiveInsideTheRepositoryIsReportedRelativeToIt(t *testing.T) {
+ home, root := homeFixture(t)
+ out := filepath.Join(root, "bin")
+ if err := os.MkdirAll(out, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ a, _, err := RenderPluginArchive(PayloadRenderRequest{
+ RepoRoot: root, Dest: filepath.Join(home, "staging"), Version: "1.2.3", Entry: sampleEntry(), Dirty: DirtySkip,
+ }, out)
+ if err != nil {
+ t.Fatalf("RenderPluginArchive: %v", err)
+ }
+ raw, err := json.Marshal(a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ Path string `json:"path"`
+ }
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ if want := "bin/" + a.Name; got.Path != want {
+ t.Errorf("archive.path = %q, want %q", got.Path, want)
+ }
+}
diff --git a/internal/surface/cli/archive.go b/internal/surface/cli/archive.go
index 89ead0c2c..244dc647a 100644
--- a/internal/surface/cli/archive.go
+++ b/internal/surface/cli/archive.go
@@ -248,7 +248,7 @@ func renderArchive(w io.Writer, rep archiveReport) {
if rep.refused() {
fmt.Fprintln(w, " written: nothing (the archive was removed)")
} else {
- fmt.Fprintf(w, " written: %s\n", termsafe.Sanitize(a.Path))
+ fmt.Fprintf(w, " written: %s\n", termsafe.Sanitize(a.DisplayPath))
}
switch {
case !rep.Pin.Checked:
diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go
index 7f449c79d..b7cc66439 100644
--- a/internal/surface/cli/ship.go
+++ b/internal/surface/cli/ship.go
@@ -761,7 +761,7 @@ func renderIngest(w io.Writer, res shipResult) {
}
// The staged path is an operator-supplied absolute location, so it is
// reported through the same sanitiser every other outside string uses.
- fmt.Fprintf(w, " payload: %s\n", termsafe.Sanitize(res.Payload.Dest))
+ fmt.Fprintf(w, " payload: %s\n", termsafe.Sanitize(res.Payload.DisplayDest))
fmt.Fprintf(w, " %d file(s), version %s in %s\n",
res.Payload.Files, res.Payload.Version, strings.Join(res.Payload.Manifests, ", "))
}
From c5eab8b89da9c18497cb5f2557eac580146e4986 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:58:32 +0100
Subject: [PATCH 16/84] fix(core): name the board's directory with the home
redacted
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bare board's dir was filepath.Abs of the working directory, so
`abcd --json` named the developer's home whenever the checkout sat under it,
against the iss-81 rule. core.Status now reports the directory through
fsutil.RedactHome. The board has no repository root to be relative to — the
directory is the thing it reports — so the home-redacted form is the display.
Nothing reads Dir back: the inspection works on the absolute path, and the
text board prints the same field.
Refs: iss-2609261950066257, iss-81
Assisted-by: Claude:claude-opus-5-5
---
commands/abcd.md | 3 ++-
internal/core/core.go | 8 +++++++-
internal/core/core_test.go | 22 ++++++++++++++++++++++
3 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/commands/abcd.md b/commands/abcd.md
index 2d00233cc..9ea814ad1 100644
--- a/commands/abcd.md
+++ b/commands/abcd.md
@@ -15,7 +15,8 @@ Run:
"${CLAUDE_PLUGIN_ROOT}/abcd" --json
```
-Then summarise the JSON for the user: the directory, whether it is a git repo,
+Then summarise the JSON for the user: the directory (`dir`, with the home
+directory written as `~`), whether it is a git repo,
whether the abcd development record is present, and which `.abcd/` work tiers
exist.
diff --git a/internal/core/core.go b/internal/core/core.go
index 6137f28eb..85cb03510 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -9,6 +9,8 @@ package core
import (
"os"
"path/filepath"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
)
// Version is abcd's version, stamped at build time via -ldflags -X (see the
@@ -29,6 +31,10 @@ func NewVersion() VersionInfo {
// StatusInfo is the result of Status: a read-only "where am I" snapshot of a
// directory, mirroring abcd's bare-invocation status convention (never mutates).
type StatusInfo struct {
+ // Dir is the inspected directory with the home redacted to "~": it travels
+ // into --json, and machine output never carries an absolute
+ // developer-identity path (iss-81, iss-2609261950066257). It is display
+ // only; the inspection reads the directory itself.
Dir string `json:"dir"`
IsGitRepo bool `json:"is_git_repo"`
HasRecord bool `json:"has_record"` // .abcd/development present
@@ -43,7 +49,7 @@ func Status(dir string) (StatusInfo, error) {
return StatusInfo{}, err
}
s := StatusInfo{
- Dir: abs,
+ Dir: fsutil.RedactHome(abs),
// .git is a directory in a normal clone but a regular gitfile in a linked
// worktree or submodule — both are genuine checkouts, so test existence, not
// dir-ness. HasRecord/WorkTiers stay dir-only (those must be directories).
diff --git a/internal/core/core_test.go b/internal/core/core_test.go
index 52c600e74..e17de9c50 100644
--- a/internal/core/core_test.go
+++ b/internal/core/core_test.go
@@ -83,3 +83,25 @@ func contains(ss []string, want string) bool {
}
return false
}
+
+// The board names its directory with the home redacted to "~": a checkout under
+// the home named the developer in `abcd --json`, against the iss-81 rule
+// (iss-2609261950066257). The text board prints the same field.
+func TestStatusNamesTheDirectoryWithoutTheHome(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ dir := filepath.Join(home, "src", "repo")
+ mustMkdir(t, filepath.Join(dir, ".git"))
+
+ s, err := Status(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := filepath.Join("~", "src", "repo"); s.Dir != want {
+ t.Errorf("Status.Dir = %q, want %q", s.Dir, want)
+ }
+ // The directory is still inspected where it is, not where it is displayed.
+ if !s.IsGitRepo {
+ t.Error("the redacted directory must not change what is inspected: IsGitRepo = false")
+ }
+}
From dc6ff0c53240a0ef27e90421fb89a18b33b7e924 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 21:00:12 +0100
Subject: [PATCH 17/84] fix(memory): report lint's paths relative to the
repository
memory lint --json named its run-log directory (report_dir), the store it
read (store_path), the coverage index (coverage_index.path) and every
finding's file absolutely, so a checkout under the home named the developer
in machine output and in the run log's report.json, against the iss-81 rule.
Each is now named through fsutil.DisplayPath relative to the repository, at
the one point Lint assembles its result, after the last finding is made. The
absolute values stay the working ones: the run log is written to the absolute
directory and the pages are read through the store handle. The tests that
read the run log back join report_dir onto the repository, and the ones that
locate a finding compare its repository-relative file.
Refs: iss-2609261950061900, iss-81
Assisted-by: Claude:claude-opus-5-5
---
commands/memory.md | 3 +-
internal/core/memory/lint.go | 19 +++-
internal/core/memory/lint_page_name_test.go | 8 +-
.../core/memory/lint_report_termsafe_test.go | 2 +-
internal/core/memory/lint_reportpaths_test.go | 87 +++++++++++++++++++
internal/core/memory/lint_residue_test.go | 18 ++--
internal/core/memory/memory_test.go | 4 +-
internal/surface/cli/memory_root_test.go | 14 ++-
8 files changed, 130 insertions(+), 25 deletions(-)
create mode 100644 internal/core/memory/lint_reportpaths_test.go
diff --git a/commands/memory.md b/commands/memory.md
index 413ced247..50637fe89 100644
--- a/commands/memory.md
+++ b/commands/memory.md
@@ -95,7 +95,8 @@ and the line, never the span, and lint never rewrites the store):
```
It rebuilds the regenerable `.coverage_index.json` and writes a report under
-`.abcd/.work.local/logs/memory/lint-/`. Summarise `summary.blockers` /
+`.abcd/.work.local/logs/memory/lint-/`. `report_dir`, `store_path` and each
+finding's `file` are named relative to the repository. Summarise `summary.blockers` /
`summary.warnings` / `summary.infos` and each finding's `code` and `message`.
Blockers exit nonzero; warn-only exits 0.
diff --git a/internal/core/memory/lint.go b/internal/core/memory/lint.go
index 661c6fe47..b4edcfc05 100644
--- a/internal/core/memory/lint.go
+++ b/internal/core/memory/lint.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/intentdriven/abcd/internal/fsutil"
"github.com/intentdriven/abcd/internal/termsafe"
)
@@ -469,7 +470,8 @@ func (l *memoryLinter) checkQuotation() {
func runMemoryCoverageLint(repoRoot string, store *storeHandle) ([]Finding, map[string]any, error) {
indexPath := CoverageIndexPath(repoRoot)
report := map[string]any{
- "path": indexPath,
+ // Display only, like every path Lint reports (iss-81).
+ "path": fsutil.DisplayPath(repoRoot, indexPath),
"stale": false,
"old_fingerprint": nil,
"new_fingerprint": nil,
@@ -609,6 +611,15 @@ func Lint(req LintRequest) (LintResult, error) {
return LintResult{}, err
}
findings = append(findings, corpusFindings...)
+ // Every path the result names travels into --json and into the run log, and
+ // machine output never carries an absolute developer-identity path (iss-81,
+ // iss-2609261950061900): each finding's file, the store and the run-log
+ // directory are named relative to the repository. The absolute values stay
+ // the working ones below.
+ for i := range findings {
+ findings[i].File = fsutil.DisplayPath(root, findings[i].File)
+ }
+ storeDisplay := fsutil.DisplayPath(root, mem)
summary := LintSummary{}
for _, f := range findings {
@@ -643,7 +654,7 @@ func Lint(req LintRequest) (LintResult, error) {
"summary": map[string]any{"blockers": summary.Blockers, "warnings": summary.Warnings, "infos": summary.Infos},
"coverage_index": coverageIndex,
"generated_at": generatedAt,
- "store_path": mem,
+ "store_path": storeDisplay,
}
if err := writeStringAtomic(filepath.Join(reportDir, "report.json"), marshalIndentNoEscape(reportFields)); err != nil {
return LintResult{}, err
@@ -656,9 +667,9 @@ func Lint(req LintRequest) (LintResult, error) {
Findings: findings,
Summary: summary,
CoverageIndex: coverageIndex,
- ReportDir: reportDir,
+ ReportDir: fsutil.DisplayPath(root, reportDir),
GeneratedAt: generatedAt,
- StorePath: mem,
+ StorePath: storeDisplay,
ExitCode: exitCode,
}, nil
}
diff --git a/internal/core/memory/lint_page_name_test.go b/internal/core/memory/lint_page_name_test.go
index 8ae7c637e..de0e0f6f4 100644
--- a/internal/core/memory/lint_page_name_test.go
+++ b/internal/core/memory/lint_page_name_test.go
@@ -70,9 +70,9 @@ func TestLintReportsASecretEmbeddedInAPageName(t *testing.T) {
t.Errorf("MR001 message carries the raw span: %q", f.Message)
}
switch f.File {
- case page:
+ case inRepo(t, repo, page):
onPage = true
- case SourcesIndexPath(repo):
+ case inRepo(t, repo, SourcesIndexPath(repo)):
onRegistry = true
if f.Line <= 0 {
t.Errorf("the registry back-link finding must locate its line, got %d", f.Line)
@@ -122,7 +122,7 @@ func TestLintReportsASecretInAnOrphanBackLink(t *testing.T) {
}
var onRegistry bool
for _, f := range pageNameMR001(res) {
- if f.File == SourcesIndexPath(repo) {
+ if f.File == inRepo(t, repo, SourcesIndexPath(repo)) {
onRegistry = true
}
}
@@ -183,7 +183,7 @@ func TestLintStillScansTheRegistryTextBesideTheBackLinks(t *testing.T) {
t.Fatalf("lint: %v", err)
}
var text int
- for _, f := range residueFindingsFor(res, SourcesIndexPath(repo)) {
+ for _, f := range residueFindingsFor(res, inRepo(t, repo, SourcesIndexPath(repo))) {
if strings.Contains(f.Message, "stored text") {
text++
}
diff --git a/internal/core/memory/lint_report_termsafe_test.go b/internal/core/memory/lint_report_termsafe_test.go
index b0b09a78b..171945c4c 100644
--- a/internal/core/memory/lint_report_termsafe_test.go
+++ b/internal/core/memory/lint_report_termsafe_test.go
@@ -54,7 +54,7 @@ func TestLintReportMDSanitisesADegradedScannerPatternName(t *testing.T) {
if !degraded {
t.Fatalf("fixture drift: no degraded-scanner MR001 naming the pattern: %+v", res.Findings)
}
- raw, err := os.ReadFile(filepath.Join(res.ReportDir, "report.md"))
+ raw, err := os.ReadFile(filepath.Join(repo, res.ReportDir, "report.md"))
if err != nil {
t.Fatal(err)
}
diff --git a/internal/core/memory/lint_reportpaths_test.go b/internal/core/memory/lint_reportpaths_test.go
new file mode 100644
index 000000000..ae344d5df
--- /dev/null
+++ b/internal/core/memory/lint_reportpaths_test.go
@@ -0,0 +1,87 @@
+package memory
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// memory lint reports its run-log directory, the store it read and every
+// finding's file without the home path: a checkout under the home named the
+// developer in `memory lint --json` through report_dir, store_path and each
+// finding's file, against the iss-81 rule (iss-2609261950061900). Each is named
+// relative to the repository, and the run log is still written where the
+// reported directory says.
+func TestLintReportsItsPathsRelativeToTheRepository(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ repo := filepath.Join(home, "src", "repo")
+ if err := os.MkdirAll(repo, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ seedResidueStore(t, repo, false)
+ plantResidue(t, filepath.Join(Dir(repo), "topic_auth_tokens.md"), map[string]string{
+ "MARKERTOKEN": "ghp_" + strings.Repeat("A", 40),
+ })
+
+ res, err := Lint(LintRequest{RepoRoot: repo, Now: fixedNow})
+ if err != nil {
+ t.Fatalf("lint: %v", err)
+ }
+ if len(res.Findings) == 0 {
+ t.Fatal("fixture drift: lint reported no finding, so no finding's file is under test")
+ }
+
+ raw, err := json.Marshal(res)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range homeSpellings(home) {
+ if strings.Contains(string(raw), h) {
+ t.Errorf("memory lint --json carries the home directory %q:\n%s", h, raw)
+ }
+ }
+ if res.StorePath != RelDir {
+ t.Errorf("store_path = %q, want %q", res.StorePath, RelDir)
+ }
+ if !strings.HasPrefix(res.ReportDir, ".abcd/.work.local/logs/memory/lint-") {
+ t.Errorf("report_dir = %q, want it relative to the repository", res.ReportDir)
+ }
+ for _, f := range res.Findings {
+ if filepath.IsAbs(f.File) {
+ t.Errorf("%s finding names its file absolutely: %q", f.Code, f.File)
+ }
+ }
+
+ for _, name := range []string{"report.json", "report.md"} {
+ data, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(res.ReportDir), name))
+ if err != nil {
+ t.Fatalf("the run log is not where report_dir says: %v", err)
+ }
+ for _, h := range homeSpellings(home) {
+ if strings.Contains(string(data), h) {
+ t.Errorf("%s carries the home directory %q", name, h)
+ }
+ }
+ }
+}
+
+func homeSpellings(home string) []string {
+ out := []string{home}
+ if real, err := filepath.EvalSymlinks(home); err == nil && real != home {
+ out = append(out, real)
+ }
+ return out
+}
+
+// inRepo is abs as Lint reports it: relative to the repository, slash-separated.
+func inRepo(t *testing.T, repo, abs string) string {
+ t.Helper()
+ rel, err := filepath.Rel(repo, abs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return filepath.ToSlash(rel)
+}
diff --git a/internal/core/memory/lint_residue_test.go b/internal/core/memory/lint_residue_test.go
index b8d7d5ef3..ab0303539 100644
--- a/internal/core/memory/lint_residue_test.go
+++ b/internal/core/memory/lint_residue_test.go
@@ -64,10 +64,10 @@ func residueFindingsFor(res LintResult, file string) []Finding {
return out
}
-func reportsMustBeClean(t *testing.T, res LintResult, spans ...string) {
+func reportsMustBeClean(t *testing.T, repo string, res LintResult, spans ...string) {
t.Helper()
for _, name := range []string{"report.json", "report.md"} {
- raw, err := os.ReadFile(filepath.Join(res.ReportDir, name))
+ raw, err := os.ReadFile(filepath.Join(repo, res.ReportDir, name))
if err != nil {
t.Fatalf("lint must always write %s: %v", name, err)
}
@@ -94,7 +94,7 @@ func TestLintReportsSecretResidueInStoredPages(t *testing.T) {
if err != nil {
t.Fatalf("lint: %v", err)
}
- found := residueFindingsFor(res, page)
+ found := residueFindingsFor(res, inRepo(t, repo, page))
if len(found) < 2 {
t.Fatalf("GHSA-xj89: lint reported %d MR001 finding(s) for a page carrying a PAT in citation.title and the body plus a home path; want the frontmatter line and the body line:\n%+v", len(found), res.Findings)
}
@@ -122,7 +122,7 @@ func TestLintReportsSecretResidueInStoredPages(t *testing.T) {
if res.ExitCode != 1 || res.Summary.Blockers < 2 {
t.Errorf("exit=%d blockers=%d, want a nonzero exit with the residue counted", res.ExitCode, res.Summary.Blockers)
}
- reportsMustBeClean(t, res, token, homePath)
+ reportsMustBeClean(t, repo, res, token, homePath)
})
t.Run("sources index", func(t *testing.T) {
@@ -137,7 +137,7 @@ func TestLintReportsSecretResidueInStoredPages(t *testing.T) {
// The marker sits on two registry lines (the entry's origin and the
// consumer citation's origin), so every located finding must name the
// kind and there must be at least one.
- found := residueFindingsFor(res, SourcesIndexPath(repo))
+ found := residueFindingsFor(res, inRepo(t, repo, SourcesIndexPath(repo)))
if len(found) == 0 {
t.Fatalf("GHSA-xj89: want MR001 on the sources index, got none: %+v", res.Findings)
}
@@ -149,7 +149,7 @@ func TestLintReportsSecretResidueInStoredPages(t *testing.T) {
if res.ExitCode != 1 {
t.Errorf("exit = %d, want 1", res.ExitCode)
}
- reportsMustBeClean(t, res, token)
+ reportsMustBeClean(t, repo, res, token)
})
t.Run("kept original", func(t *testing.T) {
@@ -165,11 +165,11 @@ func TestLintReportsSecretResidueInStoredPages(t *testing.T) {
if err != nil {
t.Fatalf("lint: %v", err)
}
- found := residueFindingsFor(res, kept)
+ found := residueFindingsFor(res, inRepo(t, repo, kept))
if len(found) != 1 || !strings.Contains(found[0].Message, "github_pat") {
t.Fatalf("GHSA-xj89: want one MR001 on the kept original naming github_pat, got %+v", found)
}
- reportsMustBeClean(t, res, token)
+ reportsMustBeClean(t, repo, res, token)
})
t.Run("clean store has no residue finding", func(t *testing.T) {
@@ -209,7 +209,7 @@ func TestLintReportsSecretResidueInStoredPages(t *testing.T) {
if res.ExitCode != 1 {
t.Errorf("exit = %d, want 1", res.ExitCode)
}
- if _, err := os.Stat(filepath.Join(res.ReportDir, "report.json")); err != nil {
+ if _, err := os.Stat(filepath.Join(repo, res.ReportDir, "report.json")); err != nil {
t.Errorf("the report was not written: %v", err)
}
})
diff --git a/internal/core/memory/memory_test.go b/internal/core/memory/memory_test.go
index d91dc2567..d975c4570 100644
--- a/internal/core/memory/memory_test.go
+++ b/internal/core/memory/memory_test.go
@@ -243,7 +243,7 @@ func TestIngestAskLintFlow(t *testing.T) {
if lr.Summary.Blockers != 0 || lr.ExitCode != 0 {
t.Fatalf("clean lint: blockers=%d exit=%d findings=%+v", lr.Summary.Blockers, lr.ExitCode, lr.Findings)
}
- if _, err := os.Stat(filepath.Join(lr.ReportDir, "report.json")); err != nil {
+ if _, err := os.Stat(filepath.Join(repo, lr.ReportDir, "report.json")); err != nil {
t.Fatalf("lint report.json not written: %v", err)
}
}
@@ -396,7 +396,7 @@ func TestLintQuotationBudgetMQ001(t *testing.T) {
if f.Severity != "warn" {
t.Errorf("MQ001 severity = %q, want warn (curator-advisory)", f.Severity)
}
- if f.File != pagePath {
+ if f.File != inRepo(t, repo, pagePath) {
t.Errorf("MQ001 file = %q, want the offending page %q", f.File, pagePath)
}
if f.Line <= 0 {
diff --git a/internal/surface/cli/memory_root_test.go b/internal/surface/cli/memory_root_test.go
index 1378a8960..caf1ec958 100644
--- a/internal/surface/cli/memory_root_test.go
+++ b/internal/surface/cli/memory_root_test.go
@@ -178,11 +178,17 @@ func TestMemoryLintFromSubdirectoryReadsAndReportsInsideTheCheckout(t *testing.T
if jerr := json.Unmarshal(out, &res); jerr != nil {
t.Fatalf("memory lint --json: not JSON: %v\n%s", jerr, out)
}
- if res.StorePath != filepath.Join(repo, filepath.FromSlash(memory.RelDir)) {
- t.Errorf("lint from a subdirectory read %q, want the checkout's store", res.StorePath)
+ // Both are reported relative to the checkout (iss-81), so the run log is
+ // found by joining report_dir onto the checkout's root: a lint rooted at
+ // the subdirectory would have written it there instead.
+ if res.StorePath != memory.RelDir {
+ t.Errorf("lint from a subdirectory read %q, want the checkout's store %q", res.StorePath, memory.RelDir)
}
- if !strings.HasPrefix(res.ReportDir, repo+string(filepath.Separator)) {
- t.Errorf("lint wrote its run log to %q, outside the checkout", res.ReportDir)
+ if filepath.IsAbs(res.ReportDir) {
+ t.Errorf("lint reported its run log absolutely: %q", res.ReportDir)
+ }
+ if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(res.ReportDir), "report.json")); err != nil {
+ t.Errorf("lint did not write its run log under the checkout at %q: %v", res.ReportDir, err)
}
noStrayMemoryStore(t, sub)
}
From 91b4ceace3744b0065e3cec84c4fe784232c7b1a Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 21:01:03 +0100
Subject: [PATCH 18/84] =?UTF-8?q?chore:=20resolve=20five=20absolute-path?=
=?UTF-8?q?=20reports=20=E2=80=94=20launch,=20the=20board=20and=20memory?=
=?UTF-8?q?=20lint?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The launch render's payload.dest, the archive's archive.path and the
bundle's resolved_path report display paths beside their working values;
the board names its directory with the home as "~"; memory lint names its
paths relative to the repository. All go through the one primitive,
fsutil.DisplayPath (or RedactHome where there is no repository root).
Resolves: iss-2609261848338673
Resolves: iss-2609261950077063
Resolves: iss-2609261954288630
Resolves: iss-2609261950066257
Resolves: iss-2609261950061900
Assisted-by: Claude:claude-opus-5-5
---
...-and-launch-ship-s-payload-line-reports.md | 14 ------------
...-its-run-log-directory-and-the-store-it.md | 14 ------------
...-and-launch-ship-s-payload-line-reports.md | 22 +++++++++++++++++++
...-its-run-log-directory-and-the-store-it.md | 22 +++++++++++++++++++
...ts-its-directory-as-an-absolute-path-in.md | 8 +++++++
...json-reports-the-archive-it-wrote-as-an.md | 8 +++++++
...and-launch-ship-json-name-every-payload.md | 8 +++++++
7 files changed, 68 insertions(+), 28 deletions(-)
delete mode 100644 .abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
delete mode 100644 .abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
create mode 100644 .abcd/work/issues/resolved/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
create mode 100644 .abcd/work/issues/resolved/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
rename .abcd/work/issues/{open => resolved}/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md (58%)
rename .abcd/work/issues/{open => resolved}/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md (50%)
rename .abcd/work/issues/{open => resolved}/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md (55%)
diff --git a/.abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md b/.abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
deleted file mode 100644
index ff8d340bb..000000000
--- a/.abcd/work/issues/open/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-schema_version: 1
-id: "iss-2609261848338673"
-slug: "launch-render-json-and-launch-ship-s-payload-line-reports"
-severity: "minor"
-category: "bug"
-source: "agent-finding"
-found_during: "autonomous run A resumed 2026-09-25"
-origin: researcher-authored
-production_mode: hand-written
-found_at: "internal/core/launch/render.go"
----
-
-launch ship --json reports the release payload's destination as an absolute path in payload.dest, and the text report prints it on its payload line: PayloadRenderResult.Dest (internal/core/launch/render.go) is the symlink-resolved destination, so a destination under the home names the developer in machine output, against the iss-81 rule the site and lifeboat verbs are held to.
diff --git a/.abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md b/.abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
deleted file mode 100644
index f70b4e3a4..000000000
--- a/.abcd/work/issues/open/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-schema_version: 1
-id: "iss-2609261950061900"
-slug: "memory-lint-reports-its-run-log-directory-and-the-store-it"
-severity: "minor"
-category: "security"
-source: "review-followup"
-found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
-origin: researcher-authored
-production_mode: hand-written
-found_at: "internal/core/memory/lint.go"
----
-
-memory lint reports its run-log directory and the store it read as absolute paths: LintResult.ReportDir and LintResult.StorePath (internal/core/memory/lint.go) are joined onto the repository root, so memory lint --json carries report_dir and store_path naming the developer's home whenever the checkout sits under it, against the iss-81 rule; the text report prints ReportDir too.
diff --git a/.abcd/work/issues/resolved/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md b/.abcd/work/issues/resolved/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
new file mode 100644
index 000000000..65a66ea72
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2609261848338673-launch-render-json-and-launch-ship-s-payload-line-reports.md
@@ -0,0 +1,22 @@
+---
+schema_version: 1
+id: "iss-2609261848338673"
+slug: "launch-render-json-and-launch-ship-s-payload-line-reports"
+severity: "minor"
+category: "bug"
+source: "agent-finding"
+found_during: "autonomous run A resumed 2026-09-25"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/launch/render.go"
+resolution: "PayloadRenderResult keeps the resolved staging directory as its working Dest (json:\"-\"), which the archive step packs from, and reports DisplayDest under the unchanged key dest through fsutil.DisplayPath: the home redacted to ~, a destination always being outside the repository. The text payload line prints the display field."
+impact: fix
+resolved_by:
+ commit: "ae58e9260"
+---
+
+launch ship --json reports the release payload's destination as an absolute path in payload.dest, and the text report prints it on its payload line: PayloadRenderResult.Dest (internal/core/launch/render.go) is the symlink-resolved destination, so a destination under the home names the developer in machine output, against the iss-81 rule the site and lifeboat verbs are held to.
+
+## Grounds
+
+- pursued: a render staged under HOME reports payload.dest as ~/staging and its JSON carries neither spelling of the home, while the payload is still written to and packed from the real directory (TestTheRenderAndTheArchiveReportTheirPathsWithoutTheHome); an absolute dest, or an archive packed from the wrong directory, would show it wrong
diff --git a/.abcd/work/issues/resolved/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md b/.abcd/work/issues/resolved/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
new file mode 100644
index 000000000..45dd41ead
--- /dev/null
+++ b/.abcd/work/issues/resolved/iss-2609261950061900-memory-lint-reports-its-run-log-directory-and-the-store-it.md
@@ -0,0 +1,22 @@
+---
+schema_version: 1
+id: "iss-2609261950061900"
+slug: "memory-lint-reports-its-run-log-directory-and-the-store-it"
+severity: "minor"
+category: "security"
+source: "review-followup"
+found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
+origin: researcher-authored
+production_mode: hand-written
+found_at: "internal/core/memory/lint.go"
+resolution: "Lint names report_dir, store_path, coverage_index.path and every finding's file relative to the repository through fsutil.DisplayPath, in the --json result and in the run log's report.json and report.md; the run log is still written to the absolute directory. The finding files and the coverage index path were in the same class and are fixed with the two fields the record names."
+impact: fix
+resolved_by:
+ commit: "dc6ff0c53"
+---
+
+memory lint reports its run-log directory and the store it read as absolute paths: LintResult.ReportDir and LintResult.StorePath (internal/core/memory/lint.go) are joined onto the repository root, so memory lint --json carries report_dir and store_path naming the developer's home whenever the checkout sits under it, against the iss-81 rule; the text report prints ReportDir too.
+
+## Grounds
+
+- pursued: a lint of a checkout under HOME reports store_path .abcd/memory, a repository-relative report_dir under which the run log exists, and no absolute finding file, and neither the JSON nor the run log carries the home (TestLintReportsItsPathsRelativeToTheRepository, TestMemoryLintFromSubdirectoryReadsAndReportsInsideTheCheckout); an absolute path in any of them would show it wrong
diff --git a/.abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md b/.abcd/work/issues/resolved/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
similarity index 58%
rename from .abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
rename to .abcd/work/issues/resolved/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
index 5cae4cd2f..3f1c3ce9a 100644
--- a/.abcd/work/issues/open/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
+++ b/.abcd/work/issues/resolved/iss-2609261950066257-the-bare-board-reports-its-directory-as-an-absolute-path-in.md
@@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/core.go"
+resolution: "core.Status reports Dir through fsutil.RedactHome, so abcd --json and the text board name a checkout under the home as ~/...; the inspection still reads the absolute directory. The board has no repository root to be relative to, since the directory is what it reports."
+impact: fix
+resolved_by:
+ commit: "c5eab8b89"
---
The bare board reports its directory as an absolute path in machine output: abcd --json carries dir as filepath.Abs of the working directory (core.Status, internal/core/core.go, embedded in the board envelope by internal/surface/cli/cli.go), so a checkout under the home names the developer in --json, against the iss-81 rule the site and lifeboat verbs are held to. The text board prints the same field on its first line.
+
+## Grounds
+
+- pursued: a checkout under HOME reports dir as ~/src/repo while IsGitRepo is still read from the real directory (TestStatusNamesTheDirectoryWithoutTheHome); an absolute dir in abcd --json would show it wrong
diff --git a/.abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md b/.abcd/work/issues/resolved/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
similarity index 50%
rename from .abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
rename to .abcd/work/issues/resolved/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
index f8ab23660..12b42fb14 100644
--- a/.abcd/work/issues/open/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
+++ b/.abcd/work/issues/resolved/iss-2609261950077063-launch-archive-json-reports-the-archive-it-wrote-as-an.md
@@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/launch/archive.go"
+resolution: "PluginArchive keeps the absolute working Path (json:\"-\"), which the archive verb removes a refused archive through, and reports DisplayPath under the unchanged key path through fsutil.DisplayPath: relative to the repository for the release workflow's --out bin, the home redacted to ~ otherwise. The text written line prints the display field."
+impact: fix
+resolved_by:
+ commit: "ae58e9260"
---
launch archive --json reports the archive it wrote as an absolute path: PluginArchive.Path (internal/core/launch/archive.go) is the --out directory made absolute by the front door and joined with the archive name, so archive.path names the developer's home whenever --out sits under it, against the iss-81 rule; the text report's written line prints the same value. Found in the drainPaths sweep of path-bearing --json fields.
+
+## Grounds
+
+- pursued: an archive written under HOME reports archive.path as ~/dist/ and one written inside the repository as bin/, with the archive still readable where it was written (TestTheRenderAndTheArchiveReportTheirPathsWithoutTheHome, TestAnArchiveInsideTheRepositoryIsReportedRelativeToIt); an absolute archive.path would show it wrong
diff --git a/.abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md b/.abcd/work/issues/resolved/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
similarity index 55%
rename from .abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
rename to .abcd/work/issues/resolved/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
index 6e616c922..c19eff925 100644
--- a/.abcd/work/issues/open/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
+++ b/.abcd/work/issues/resolved/iss-2609261954288630-launch-dry-run-json-and-launch-ship-json-name-every-payload.md
@@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainSite"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/launch/bundle.go"
+resolution: "IncludedFile keeps the absolute ResolvedPath (json:\"-\") that the render, the gates, the scan and the parity diff read every payload file through, and reports DisplayResolvedPath under the unchanged key resolved_path, relative to the repository, set once where the resolver emits each included file."
+impact: fix
+resolved_by:
+ commit: "ae58e9260"
---
launch --dry-run --json and launch ship --json name every payload file absolutely: IncludedFile.ResolvedPath (internal/core/launch/bundle.go) is the file's absolute on-disk path and is tagged resolved_path, so the bundle's files list in the dry-run report and in a ship's payload.bundle carries the checkout's absolute path once per file, naming the developer's home whenever the checkout sits under it, against the iss-81 rule. Found in the drainPaths sweep by running the read-only --json verbs from a checkout under the home.
+
+## Grounds
+
+- pursued: every bundle file of a render from a checkout under HOME reports a repository-relative resolved_path and the render's JSON carries neither spelling of the home (TestTheRenderAndTheArchiveReportTheirPathsWithoutTheHome); an absolute resolved_path in launch --dry-run --json or in a ship's payload.bundle would show it wrong
From 989f69a8d17c8e5739ad818f28bb28bc8d53a642 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:35:46 +0100
Subject: [PATCH 19/84] fix(memory): print the board's drift line on the text
render
The bare memory board set a drift list that --json emitted and the human
render never printed, so the one line asking the reader to act reached a
parser and not a person. The text board now prints each drift line in the
words the JSON carries, and the lines say, in the present tense, which
file is stale and that `abcd memory ingest` rebuilds it. The brief
chapter and the command page say the board carries drift.
Refs: iss-2609091647582259
Assisted-by: Claude:claude-opus-5-5
---
.../brief/04-surfaces/07-memory.md | 12 +++--
commands/memory.md | 6 ++-
internal/core/memory/bare.go | 8 ++-
internal/surface/cli/cli.go | 5 ++
internal/surface/cli/memory_drift_test.go | 54 +++++++++++++++++++
5 files changed, 76 insertions(+), 9 deletions(-)
create mode 100644 internal/surface/cli/memory_drift_test.go
diff --git a/.abcd/development/brief/04-surfaces/07-memory.md b/.abcd/development/brief/04-surfaces/07-memory.md
index 9c6936e4f..fa5b72897 100644
--- a/.abcd/development/brief/04-surfaces/07-memory.md
+++ b/.abcd/development/brief/04-surfaces/07-memory.md
@@ -44,11 +44,13 @@ surface contract: what the user types and what happens.
**Bare `/abcd:memory`** renders the store's state and nothing else: how many
pages there are by class, when the last ingest happened, the recent
-contradictions, and per-source quotation-budget headroom. It never mutates and
-never rebuilds an index. The JSON render carries one element the text render
-drops, a `drift` list saying that the catalogue or the contradictions register
-no longer hash-matches what the store's pages would render, so a reader knows
-the numbers are stale rather than wrong. Headroom is read-only in the same
+contradictions, per-source quotation-budget headroom, and drift. It never
+mutates and never rebuilds an index. Drift is a line saying that the catalogue
+or the contradictions register no longer hash-matches what the store's pages
+would render, naming `abcd memory ingest` as the verb that rebuilds it, so a
+reader knows the numbers are stale rather than wrong; the text board prints
+each line in the words the JSON's `drift` list carries, and a current store
+prints none. Headroom is read-only in the same
spirit: a fresh index shows per-source warn and block headroom, a drifted one
says to run the lint, and an absent or unreadable one says the headroom is
unavailable rather than guessing at it.
diff --git a/commands/memory.md b/commands/memory.md
index 50637fe89..438e4f5a4 100644
--- a/commands/memory.md
+++ b/commands/memory.md
@@ -29,8 +29,10 @@ of the checkout's store.
```
Summarise the JSON: `pages` and `by_class` (page count per source class),
-`last_ingest`, any `contradictions`, and per-source `headroom` lines. The bare
-render never rebuilds or mutates the coverage index.
+`last_ingest`, any `contradictions`, per-source `headroom` lines, and every
+`drift` line verbatim — each says the index or the contradictions register is
+stale and names the ingest that rebuilds it. The bare render never rebuilds or
+mutates the coverage index.
## Ingest a source
diff --git a/internal/core/memory/bare.go b/internal/core/memory/bare.go
index 370f1b21a..dade51d00 100644
--- a/internal/core/memory/bare.go
+++ b/internal/core/memory/bare.go
@@ -100,11 +100,15 @@ func Bare(repoRoot string) (BareStatus, error) {
stale[name] = true
}
}
+ // One wording for both surfaces: the text board prints these lines
+ // verbatim, so a person and a parser read the same sentence, and the
+ // sentence names the verb that heals it (every ingest reconciles the
+ // index and the register before it writes).
if stale["index.md"] {
- status.Drift = append(status.Drift, "index stale; run an ingest")
+ status.Drift = append(status.Drift, "the index is stale — run `abcd memory ingest` to rebuild it")
}
if stale["contradictions.md"] {
- status.Drift = append(status.Drift, "contradictions register stale; run an ingest")
+ status.Drift = append(status.Drift, "the contradictions register is stale — run `abcd memory ingest` to rebuild it")
}
}
diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go
index 3aee32488..9a3e47a78 100644
--- a/internal/surface/cli/cli.go
+++ b/internal/surface/cli/cli.go
@@ -4700,6 +4700,11 @@ func newMemoryCommand(asJSON *bool) *cobra.Command {
if st.LastIngest != "" {
fmt.Fprintf(w, " last ingest: %s\n", termsafe.Sanitize(st.LastIngest))
}
+ // Drift is the board's one call to action, and it is printed in the
+ // words the JSON carries (iss-2609091647582259).
+ for _, line := range st.Drift {
+ fmt.Fprintf(w, " %s\n", line)
+ }
for _, line := range st.Contradictions {
fmt.Fprintf(w, " contradiction: %s\n", termsafe.Sanitize(line))
}
diff --git a/internal/surface/cli/memory_drift_test.go b/internal/surface/cli/memory_drift_test.go
new file mode 100644
index 000000000..9e5fa28c7
--- /dev/null
+++ b/internal/surface/cli/memory_drift_test.go
@@ -0,0 +1,54 @@
+package cli
+
+import (
+ "strings"
+ "testing"
+)
+
+// The bare memory board tells a person what the --json envelope tells a parser.
+// The drift list — the index or the contradictions register no longer matching
+// what the store's pages render — is the one line on the board that asks the
+// reader to do something, and the text render used to drop it, so the only
+// reader who could act on it was the one who asked for machine output
+// (iss-2609091647582259).
+
+// TestMemoryBoardPrintsItsDriftOnTheTextRender: a store whose index is stale
+// says so on the human board, in the words the JSON carries, and names the verb
+// that heals it.
+func TestMemoryBoardPrintsItsDriftOnTheTextRender(t *testing.T) {
+ memoryStoreFixture(t) // one page, no index.md: the index is stale
+
+ st := memoryBoard(t)
+ if len(st.Drift) == 0 {
+ t.Fatalf("fixture drift: a store with no index.md reports no drift in --json: %+v", st)
+ }
+ text := string(runCLI(t, "memory"))
+ for _, line := range st.Drift {
+ if !strings.Contains(text, line) {
+ t.Errorf("the text board omits the drift line the JSON carries, %q:\n%s", line, text)
+ }
+ if !strings.Contains(line, "abcd memory ingest") {
+ t.Errorf("drift line %q does not name the verb that heals it", line)
+ }
+ }
+}
+
+// TestMemoryBoardIsQuietAboutDriftWhenTheStoreIsCurrent is the anti-vacuity
+// control: after an ingest re-renders the index and the register, neither
+// surface reports drift, so the text line above is a fact about the store and
+// not a fixed banner.
+func TestMemoryBoardIsQuietAboutDriftWhenTheStoreIsCurrent(t *testing.T) {
+ repo, _ := memoryStoreFixture(t)
+ src, pages := ingestOperands(t, repo)
+ if out, err := runCLIErr(t, "memory", "ingest", src, "--pages-json", pages); err != nil {
+ t.Fatalf("memory ingest: %v\n%s", err, out)
+ }
+
+ st := memoryBoard(t)
+ if len(st.Drift) != 0 {
+ t.Fatalf("a store an ingest just re-rendered reports drift in --json: %q", st.Drift)
+ }
+ if text := string(runCLI(t, "memory")); strings.Contains(text, "stale") {
+ t.Errorf("a current store's text board reports staleness:\n%s", text)
+ }
+}
From c5576af1c16a7c9f3d9331959b7bdd19c44227e4 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:35:55 +0100
Subject: [PATCH 20/84] =?UTF-8?q?chore:=20resolve=20iss-2609091647582259?=
=?UTF-8?q?=20=E2=80=94=20the=20memory=20board=20prints=20its=20drift?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves: iss-2609091647582259
Assisted-by: Claude:claude-opus-5-5
---
...y-board-hides-its-staleness-warning-from-the-reader.md | 8 ++++++++
1 file changed, 8 insertions(+)
rename .abcd/work/issues/{open => resolved}/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md (71%)
diff --git a/.abcd/work/issues/open/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md b/.abcd/work/issues/resolved/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md
similarity index 71%
rename from .abcd/work/issues/open/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md
rename to .abcd/work/issues/resolved/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md
index d2066965e..329ff88bd 100644
--- a/.abcd/work/issues/open/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md
+++ b/.abcd/work/issues/resolved/iss-2609091647582259-the-memory-board-hides-its-staleness-warning-from-the-reader.md
@@ -9,6 +9,14 @@ found_during: "release-gate"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/memory/bare.go"
+resolution: "The bare memory board prints every drift line on the text render in the words the --json drift list carries, and each line names abcd memory ingest as the verb that rebuilds the stale file; a current store prints none."
+impact: fix
+resolved_by:
+ commit: "989f69a8d"
---
The memory store's bare status carries a drift field that says the index is stale and an ingest should be run. It is set on the result, it is emitted under the JSON envelope, and the human render never prints it, so the only reader who can act on the warning is the one who asked for machine output. The person who typed the bare verb to see how the store is doing is shown everything except the one line that asks them to do something. That is the loud-staging principle inverted: a degraded state that announces itself to a parser and stays quiet to a person, which is the shape the principle exists to refuse, and it is worse than silence because the board looks complete. Fix direction: print the drift line in the text render beside the counts it already shows, in the same words the JSON carries, so the two surfaces say one thing. Detector: a store whose index is stale renders the staleness on the bare human board as well as in the JSON envelope, and a store that is current renders neither.
+
+## Grounds
+
+- pursued: a store with no index.md shows the index-stale line on the text board and a freshly ingested store shows no stale line on either surface; a stale store whose text board omits a line the JSON carries would show it wrong
From 7d5785bef18049c867641f08ec4db5553f058473 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:43:22 +0100
Subject: [PATCH 21/84] fix(memory): prove the local tier before lint writes
its run log
memory lint joined .abcd/.work.local/logs/memory/lint- onto the
checkout root, MkdirAll-ed it and wrote both reports by path, so a
checkout carrying the local tier (or any level below it) as a committed
symlink had the directory chain and both reports created at the link's
target, outside the checkout.
fsutil.CreateRunDir is the local tier's run-log create path: it proves
every level from the checkout root down with EnsureRealDirAll, then
creates the run directory exclusively (name, name-001, ...), so two runs
in one instant keep two logs. fsutil.OpenRealDir opens the proved
directory as an os.Root only when it is still a real directory, and lint
writes both reports through that handle with WriteFileAtomicInRoot, so a
level swapped for a link after the proof cannot carry the writes away.
The tests plant the link at the tier, at logs/ and at logs/memory/, and
hold that lint refuses with ErrNotRealDir and writes nothing at the
target; the control writes under a real, partly present tier.
Refs: iss-2609260948440803
Assisted-by: Claude:claude-opus-5-5
---
internal/core/memory/lint.go | 56 ++++++++------
internal/core/memory/lint_localtier_test.go | 79 +++++++++++++++++++
internal/fsutil/rundir.go | 61 +++++++++++++++
internal/fsutil/rundir_test.go | 86 +++++++++++++++++++++
4 files changed, 259 insertions(+), 23 deletions(-)
create mode 100644 internal/core/memory/lint_localtier_test.go
create mode 100644 internal/fsutil/rundir.go
create mode 100644 internal/fsutil/rundir_test.go
diff --git a/internal/core/memory/lint.go b/internal/core/memory/lint.go
index b4edcfc05..ca6b83ffb 100644
--- a/internal/core/memory/lint.go
+++ b/internal/core/memory/lint.go
@@ -642,13 +642,10 @@ func Lint(req LintRequest) (LintResult, error) {
coverageIndex = coverageReport
}
- reportDir, err := lintReportDir(root, now)
+ reportDir, err := makeLintReportDir(root, now)
if err != nil {
return LintResult{}, err
}
- if err := os.MkdirAll(reportDir, 0o755); err != nil {
- return LintResult{}, err
- }
reportFields := map[string]any{
"findings": findingsToMaps(findings),
"summary": map[string]any{"blockers": summary.Blockers, "warnings": summary.Warnings, "infos": summary.Infos},
@@ -656,10 +653,7 @@ func Lint(req LintRequest) (LintResult, error) {
"generated_at": generatedAt,
"store_path": storeDisplay,
}
- if err := writeStringAtomic(filepath.Join(reportDir, "report.json"), marshalIndentNoEscape(reportFields)); err != nil {
- return LintResult{}, err
- }
- if err := writeStringAtomic(filepath.Join(reportDir, "report.md"), renderLintReportMD(reportFields)); err != nil {
+ if err := writeLintReports(reportDir, marshalIndentNoEscape(reportFields), renderLintReportMD(reportFields)); err != nil {
return LintResult{}, err
}
@@ -674,22 +668,38 @@ func Lint(req LintRequest) (LintResult, error) {
}, nil
}
-func lintReportDir(repoRoot string, now time.Time) (string, error) {
- ts := now.Format("20060102T150405.000000Z")
- // Runtime artefacts live in the gitignored .abcd/.work.local/logs/ tier, not
- // the retired runtime location (iss-36/iss-56 adjudication, iss-73).
- logs := filepath.Join(repoRoot, ".abcd", ".work.local", "logs", "memory")
- base := filepath.Join(logs, "lint-"+ts)
- if _, err := os.Stat(base); os.IsNotExist(err) {
- return base, nil
- }
- for n := 1; n < 1000; n++ {
- candidate := filepath.Join(logs, fmt.Sprintf("lint-%s-%03d", ts, n))
- if _, err := os.Stat(candidate); os.IsNotExist(err) {
- return candidate, nil
- }
+// lintLogsRelDir is where every lint run keeps its run log: the gitignored
+// local tier, not the retired runtime location (iss-36/iss-56 adjudication,
+// iss-73).
+const lintLogsRelDir = ".abcd/.work.local/logs/memory"
+
+// makeLintReportDir creates this run's own directory under lintLogsRelDir
+// through the local tier's run-log create path (fsutil.CreateRunDir): every
+// level from the checkout root down is proved a real directory first, so a
+// local tier — or any level below it — that a checkout carries as a committed
+// symlink is refused rather than followed out of the checkout
+// (iss-2609260948440803), and the run directory is created exclusively.
+func makeLintReportDir(repoRoot string, now time.Time) (string, error) {
+ dir, err := fsutil.CreateRunDir(repoRoot, lintLogsRelDir, "lint-"+now.Format("20060102T150405.000000Z"), 0o755)
+ if err != nil {
+ return "", fmt.Errorf("the lint run-log directory: %w", err)
+ }
+ return dir, nil
+}
+
+// writeLintReports writes both reports through a handle on the run directory
+// makeLintReportDir just created (fsutil.OpenRealDir), so a level swapped for a
+// link after the proof cannot carry the writes elsewhere.
+func writeLintReports(dir, reportJSON, reportMD string) error {
+ root, err := fsutil.OpenRealDir(dir)
+ if err != nil {
+ return err
+ }
+ defer root.Close()
+ if err := fsutil.WriteFileAtomicInRoot(root, "report.json", []byte(reportJSON), 0o644); err != nil {
+ return err
}
- return "", fmt.Errorf("could not allocate a unique lint run-log dir for %s", ts)
+ return fsutil.WriteFileAtomicInRoot(root, "report.md", []byte(reportMD), 0o644)
}
func findingsToMaps(findings []Finding) []any {
diff --git a/internal/core/memory/lint_localtier_test.go b/internal/core/memory/lint_localtier_test.go
new file mode 100644
index 000000000..92373b72f
--- /dev/null
+++ b/internal/core/memory/lint_localtier_test.go
@@ -0,0 +1,79 @@
+package memory
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
+)
+
+// memory lint writes its run log into the checkout's local tier, and a checkout
+// can carry any level of that tier as a committed symlink (a committed link beats
+// .gitignore). A by-path MkdirAll and write followed such a link out of the
+// checkout and wrote both reports at its target (iss-2609260948440803). Every
+// level from the checkout root down to the run directory is proved real before
+// anything is created under it, and the reports are written through a handle on
+// the proved directory.
+
+// TestLintRefusesASymlinkedLocalTierAncestor plants the link at each level of
+// the chain in turn — the tier itself, then logs/ below a real tier — and holds
+// that lint refuses and that nothing lands at the link's target.
+func TestLintRefusesASymlinkedLocalTierAncestor(t *testing.T) {
+ for _, level := range []string{".abcd/.work.local", ".abcd/.work.local/logs", ".abcd/.work.local/logs/memory"} {
+ t.Run(level, func(t *testing.T) {
+ repo := t.TempDir()
+ seedResidueStore(t, repo, false)
+ outside := t.TempDir()
+
+ link := filepath.Join(repo, filepath.FromSlash(level))
+ if err := os.RemoveAll(link); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, link); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := Lint(LintRequest{RepoRoot: repo, Now: fixedNow})
+ if err == nil {
+ t.Fatalf("lint with %s symlinked out of the checkout returned nil; it must refuse", level)
+ }
+ if !errors.Is(err, fsutil.ErrNotRealDir) {
+ t.Errorf("lint refused for the wrong reason: %v", err)
+ }
+ entries, rerr := os.ReadDir(outside)
+ if rerr != nil {
+ t.Fatal(rerr)
+ }
+ if len(entries) != 0 {
+ t.Errorf("lint wrote %d entr(y|ies) at the link's target outside the checkout (%s first)", len(entries), entries[0].Name())
+ }
+ })
+ }
+}
+
+// TestLintWritesItsRunLogUnderARealNestedTier is the control: a tier whose every
+// level is a real directory — some already present, some created by this run —
+// still receives both reports where report_dir says.
+func TestLintWritesItsRunLogUnderARealNestedTier(t *testing.T) {
+ repo := t.TempDir()
+ seedResidueStore(t, repo, false)
+ if err := os.MkdirAll(filepath.Join(repo, ".abcd", ".work.local", "logs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Lint(LintRequest{RepoRoot: repo, Now: fixedNow})
+ if err != nil {
+ t.Fatalf("lint under a real tier: %v", err)
+ }
+ for _, name := range []string{"report.json", "report.md"} {
+ p := filepath.Join(repo, filepath.FromSlash(res.ReportDir), name)
+ if fi, err := os.Lstat(p); err != nil || !fi.Mode().IsRegular() {
+ t.Errorf("%s is not a regular file under the reported run directory: %v", p, err)
+ }
+ }
+}
diff --git a/internal/fsutil/rundir.go b/internal/fsutil/rundir.go
new file mode 100644
index 000000000..cde694cc5
--- /dev/null
+++ b/internal/fsutil/rundir.go
@@ -0,0 +1,61 @@
+package fsutil
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// maxRunDirSuffix bounds the same-instant collision suffix: name, name-001 …
+// name-999. A thousand runs in one timestamp is a runaway loop, not a workload.
+const maxRunDirSuffix = 999
+
+// CreateRunDir creates a fresh directory for one run's log under rel below base
+// and returns its path. It is the local tier's run-log create path: every level
+// from base down through rel is proved a real directory first (EnsureRealDirAll,
+// creating what is missing), so a tier a checkout carries as a committed symlink
+// — at any level — is refused rather than followed out of the checkout. The run
+// directory itself is then created exclusively, never reused: name when it is
+// free, else name-001, name-002 and so on, so two runs in one instant keep two
+// logs.
+//
+// The directory is returned as a path because callers report it; a caller that
+// writes into it opens it with OpenRealDir, which re-proves the leaf and pins the
+// writes to the directory handle, so a level swapped for a link after this proof
+// cannot carry them elsewhere.
+func CreateRunDir(base, rel, name string, perm os.FileMode) (string, error) {
+ if err := EnsureRealDirAll(base, rel, perm); err != nil {
+ return "", err
+ }
+ if !ValidRelPath(name) || filepath.Base(name) != name {
+ return "", &os.PathError{Op: "createrundir", Path: name, Err: os.ErrInvalid}
+ }
+ parent := filepath.Join(base, filepath.FromSlash(rel))
+ for n := 0; n <= maxRunDirSuffix; n++ {
+ candidate := name
+ if n > 0 {
+ candidate = fmt.Sprintf("%s-%03d", name, n)
+ }
+ dir := filepath.Join(parent, candidate)
+ err := os.Mkdir(dir, perm)
+ if err == nil {
+ return dir, nil
+ }
+ if !errors.Is(err, os.ErrExist) {
+ return "", err
+ }
+ }
+ return "", fmt.Errorf("fsutil: no free run directory for %s after %d attempts", name, maxRunDirSuffix+1)
+}
+
+// OpenRealDir opens dir as an os.Root only when it is a real directory rather
+// than a symlink or a non-directory, so every write through the handle lands in
+// the directory that was proved and nowhere its path might name later. The
+// caller closes the root.
+func OpenRealDir(dir string) (*os.Root, error) {
+ if !IsRealDir(dir) {
+ return nil, &os.PathError{Op: "openrealdir", Path: dir, Err: ErrNotRealDir}
+ }
+ return os.OpenRoot(dir)
+}
diff --git a/internal/fsutil/rundir_test.go b/internal/fsutil/rundir_test.go
new file mode 100644
index 000000000..5b51a38b1
--- /dev/null
+++ b/internal/fsutil/rundir_test.go
@@ -0,0 +1,86 @@
+package fsutil
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestCreateRunDirRefusesASymlinkedAncestor: a link at any level of the chain —
+// not only the leaf — is refused, and nothing is created at its target.
+func TestCreateRunDirRefusesASymlinkedAncestor(t *testing.T) {
+ for _, level := range []string{".abcd", ".abcd/.work.local", ".abcd/.work.local/logs"} {
+ t.Run(level, func(t *testing.T) {
+ base, elsewhere := t.TempDir(), t.TempDir()
+ link := filepath.Join(base, filepath.FromSlash(level))
+ if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(elsewhere, link); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ _, err := CreateRunDir(base, ".abcd/.work.local/logs/memory", "lint-x", 0o755)
+ if !errors.Is(err, ErrNotRealDir) {
+ t.Fatalf("CreateRunDir through a symlinked %s = %v, want ErrNotRealDir", level, err)
+ }
+ if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
+ t.Errorf("created %d entr(y|ies) through the symlink", len(entries))
+ }
+ })
+ }
+}
+
+// TestCreateRunDirCreatesAFreshDirectoryPerRun is the ordinary path: the chain
+// is created, and a second run with the same name gets name-001 rather than
+// sharing the first run's directory.
+func TestCreateRunDirCreatesAFreshDirectoryPerRun(t *testing.T) {
+ base := t.TempDir()
+ first, err := CreateRunDir(base, ".abcd/.work.local/logs/memory", "lint-x", 0o755)
+ if err != nil {
+ t.Fatalf("first run: %v", err)
+ }
+ second, err := CreateRunDir(base, ".abcd/.work.local/logs/memory", "lint-x", 0o755)
+ if err != nil {
+ t.Fatalf("second run: %v", err)
+ }
+ if filepath.Base(first) != "lint-x" || filepath.Base(second) != "lint-x-001" {
+ t.Errorf("run directories = %s, %s; want lint-x, lint-x-001", filepath.Base(first), filepath.Base(second))
+ }
+ for _, d := range []string{first, second} {
+ if !IsRealDir(d) {
+ t.Errorf("%s is not a real directory", d)
+ }
+ }
+}
+
+// TestCreateRunDirRefusesANameThatIsAPath keeps the run directory one level
+// deep: a name carrying a separator or a traversal is refused before anything
+// under the proved chain is created.
+func TestCreateRunDirRefusesANameThatIsAPath(t *testing.T) {
+ base := t.TempDir()
+ for _, name := range []string{"a/b", "..", "../x", ""} {
+ if _, err := CreateRunDir(base, "logs", name, 0o755); !errors.Is(err, os.ErrInvalid) {
+ t.Errorf("CreateRunDir(name=%q) = %v, want os.ErrInvalid", name, err)
+ }
+ }
+}
+
+// TestOpenRealDirRefusesALink: the write handle is opened on a real directory
+// only, so a run directory swapped for a link is refused rather than written
+// through.
+func TestOpenRealDirRefusesALink(t *testing.T) {
+ base, elsewhere := t.TempDir(), t.TempDir()
+ link := filepath.Join(base, "run")
+ if err := os.Symlink(elsewhere, link); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ if _, err := OpenRealDir(link); !errors.Is(err, ErrNotRealDir) {
+ t.Fatalf("OpenRealDir(link) = %v, want ErrNotRealDir", err)
+ }
+ root, err := OpenRealDir(base)
+ if err != nil {
+ t.Fatalf("OpenRealDir(real) = %v", err)
+ }
+ root.Close()
+}
From 934ae94bac721ebef408772177776c0fb29028ff Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:43:24 +0100
Subject: [PATCH 22/84] fix(capture,reading): prove the local tier before the
drift receipt and run directory
The two sibling writers of the memory lint run log had the same shape:
the issue-drift receipt (.abcd/.work.local/logs/audit/issue-drift-)
and the reading assembler's run directory (by default under
.abcd/.work.local/scratch/reading-runs/) were created with a by-path
MkdirAll, so a committed symlink at any level of the local tier carried
the receipt or the assembled input and its manifest out of the checkout.
The drift receipt goes through fsutil.CreateRunDir and is written through
fsutil.OpenRealDir. The reading assembler proves every level of a run
directory named inside the repository with EnsureRealDirAll and creates
the leaf with EnsureRealDir; a directory named outside the repository
(absolute, or climbing out of it) is the operator's own and is taken as
given, as before. Each has a test planting the link at every level of
its chain, and the drift control holds that two runs in one instant get
two receipts.
Refs: iss-2609260948440803
Assisted-by: Claude:claude-opus-5-5
---
internal/core/capture/drift.go | 31 ++++----
internal/core/capture/drift_localtier_test.go | 72 +++++++++++++++++++
internal/core/reading/assemble.go | 18 ++++-
.../core/reading/assemble_localtier_test.go | 46 ++++++++++++
4 files changed, 149 insertions(+), 18 deletions(-)
create mode 100644 internal/core/capture/drift_localtier_test.go
create mode 100644 internal/core/reading/assemble_localtier_test.go
diff --git a/internal/core/capture/drift.go b/internal/core/capture/drift.go
index 013438f18..c58022a1a 100644
--- a/internal/core/capture/drift.go
+++ b/internal/core/capture/drift.go
@@ -3,7 +3,6 @@ package capture
import (
"encoding/json"
"fmt"
- "os"
"path/filepath"
"sort"
"strings"
@@ -168,28 +167,26 @@ func IssueDrift(req IssueDriftRequest) (IssueDriftResult, error) {
// writeDriftReceipt allocates this run's receipt directory and writes the
// report into it, returning its repo-relative path.
func writeDriftReceipt(repoRoot string, now time.Time, res *IssueDriftResult) (string, error) {
- base := filepath.Join(repoRoot, driftReceiptRelDir)
- stamp := "issue-drift-" + now.UTC().Format("20060102T150405Z")
- dir := filepath.Join(base, stamp)
- for n := 1; ; n++ {
- if _, err := os.Lstat(dir); os.IsNotExist(err) {
- break
- }
- if n >= 1000 {
- return "", fmt.Errorf("issue drift: could not allocate a unique receipt directory for %s", stamp)
- }
- dir = filepath.Join(base, fmt.Sprintf("%s-%03d", stamp, n))
- }
- if err := os.MkdirAll(dir, 0o755); err != nil {
+ // The local tier's run-log create path: every level from the checkout root
+ // down is proved real before the receipt directory is created under it, so a
+ // tier the checkout carries as a committed symlink is refused rather than
+ // followed out of the checkout.
+ dir, err := fsutil.CreateRunDir(repoRoot, filepath.ToSlash(driftReceiptRelDir),
+ "issue-drift-"+now.UTC().Format("20060102T150405Z"), 0o755)
+ if err != nil {
return "", fmt.Errorf("issue drift: %w", err)
}
- path := filepath.Join(dir, "report.json")
- res.ReceiptPath = fsutil.RepoRel(repoRoot, path)
+ res.ReceiptPath = fsutil.RepoRel(repoRoot, filepath.Join(dir, "report.json"))
data, err := json.MarshalIndent(res, "", " ")
if err != nil {
return "", err
}
- if err := fsutil.WriteFileAtomicPreserveMode(path, append(data, '\n')); err != nil {
+ root, err := fsutil.OpenRealDir(dir)
+ if err != nil {
+ return "", fmt.Errorf("issue drift: %w", err)
+ }
+ defer root.Close()
+ if err := fsutil.WriteFileAtomicInRoot(root, "report.json", append(data, '\n'), 0o644); err != nil {
return "", fmt.Errorf("issue drift: %w", err)
}
return res.ReceiptPath, nil
diff --git a/internal/core/capture/drift_localtier_test.go b/internal/core/capture/drift_localtier_test.go
new file mode 100644
index 000000000..d261ce129
--- /dev/null
+++ b/internal/core/capture/drift_localtier_test.go
@@ -0,0 +1,72 @@
+package capture
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
+)
+
+// The issue-drift receipt is written into the checkout's local tier, and a
+// checkout can carry any level of that tier as a committed symlink. A by-path
+// MkdirAll followed such a link out of the checkout and wrote the receipt at its
+// target — the sibling of iss-2609260948440803's memory lint write. Every level
+// is proved real before the receipt directory is created under it.
+
+// TestIssueDriftRefusesASymlinkedLocalTierAncestor plants the link at each level
+// of the receipt chain and holds that the check refuses and writes nothing at
+// the link's target.
+func TestIssueDriftRefusesASymlinkedLocalTierAncestor(t *testing.T) {
+ for _, level := range []string{".abcd/.work.local", ".abcd/.work.local/logs", ".abcd/.work.local/logs/audit"} {
+ t.Run(level, func(t *testing.T) {
+ repo, ir := driftFixture(t)
+ outside := t.TempDir()
+ link := filepath.Join(repo, filepath.FromSlash(level))
+ if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, link); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := IssueDrift(IssueDriftRequest{RepoRoot: repo, IssuesRoot: ir, Now: time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)})
+ if err == nil {
+ t.Fatalf("issue drift with %s symlinked out of the checkout returned nil; it must refuse", level)
+ }
+ if !errors.Is(err, fsutil.ErrNotRealDir) {
+ t.Errorf("issue drift refused for the wrong reason: %v", err)
+ }
+ if entries, _ := os.ReadDir(outside); len(entries) != 0 {
+ t.Errorf("issue drift wrote %d entr(y|ies) at the link's target outside the checkout", len(entries))
+ }
+ })
+ }
+}
+
+// TestIssueDriftWritesItsReceiptUnderARealNestedTier is the control: a real
+// tier, partly present, still receives the receipt where ReceiptPath says, and a
+// second run in the same instant gets a directory of its own.
+func TestIssueDriftWritesItsReceiptUnderARealNestedTier(t *testing.T) {
+ repo, ir := driftFixture(t)
+ if err := os.MkdirAll(filepath.Join(repo, ".abcd", ".work.local", "logs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
+ seen := map[string]bool{}
+ for i := 0; i < 2; i++ {
+ res, err := IssueDrift(IssueDriftRequest{RepoRoot: repo, IssuesRoot: ir, Now: now})
+ if err != nil {
+ t.Fatalf("issue drift under a real tier: %v", err)
+ }
+ if seen[res.ReceiptPath] {
+ t.Fatalf("two runs in one instant share the receipt %s", res.ReceiptPath)
+ }
+ seen[res.ReceiptPath] = true
+ if fi, err := os.Lstat(filepath.Join(repo, filepath.FromSlash(res.ReceiptPath))); err != nil || !fi.Mode().IsRegular() {
+ t.Errorf("the receipt %s is not a regular file: %v", res.ReceiptPath, err)
+ }
+ }
+}
diff --git a/internal/core/reading/assemble.go b/internal/core/reading/assemble.go
index 454ae55d0..1ebe368af 100644
--- a/internal/core/reading/assemble.go
+++ b/internal/core/reading/assemble.go
@@ -725,10 +725,26 @@ func writeArtefacts(repoRoot, outDir, label string, b Bundle, m Manifest) error
if label == "" {
label = outDir
}
+ // A run directory named inside the repository — the default one in the
+ // local tier above all — is created one proved level at a time: a level the
+ // checkout carries as a committed symlink is refused rather than followed out
+ // of it. A directory named outside the repository (absolute, or climbing out
+ // of it) is the operator's own and is taken as given.
+ rel := path.Clean(filepath.ToSlash(outDir))
+ inRepo := !filepath.IsAbs(outDir) && fsutil.ValidRelPath(rel)
+ if inRepo && path.Dir(rel) != "." {
+ if err := fsutil.EnsureRealDirAll(repoRoot, path.Dir(rel), 0o755); err != nil {
+ return fmt.Errorf("reading: creating the run directory: %w", err)
+ }
+ }
if err := requireEmptyDir(label, dir); err != nil {
return err
}
- if err := os.MkdirAll(dir, 0o755); err != nil {
+ if inRepo {
+ if err := fsutil.EnsureRealDir(dir, 0o755); err != nil {
+ return fmt.Errorf("reading: creating the run directory: %w", err)
+ }
+ } else if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("reading: creating the run directory: %w", err)
}
bundleRaw, err := EncodeBundle(b)
diff --git a/internal/core/reading/assemble_localtier_test.go b/internal/core/reading/assemble_localtier_test.go
new file mode 100644
index 000000000..cb73c837b
--- /dev/null
+++ b/internal/core/reading/assemble_localtier_test.go
@@ -0,0 +1,46 @@
+package reading
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
+)
+
+// The default run directory lives in the checkout's local tier, and a checkout
+// can carry any level of that tier as a committed symlink (a committed link beats
+// .gitignore). A by-path MkdirAll followed such a link out of the checkout and
+// wrote the assembled input and its manifest at the link's target — the sibling
+// of iss-2609260948440803's memory lint write. Every level of a run directory
+// named inside the repository is proved real before it is created.
+
+// TestAssembleRefusesASymlinkedLocalTierAncestor plants the link at each level
+// of the default run directory's chain and holds that the assembly refuses and
+// writes nothing at the link's target.
+func TestAssembleRefusesASymlinkedLocalTierAncestor(t *testing.T) {
+ for _, level := range []string{".abcd/.work.local", ".abcd/.work.local/scratch", DefaultRunDir} {
+ t.Run(level, func(t *testing.T) {
+ root := fixtureRepo(t)
+ outside := t.TempDir()
+ link := filepath.Join(root, filepath.FromSlash(level))
+ if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, link); err != nil {
+ t.Fatal(err)
+ }
+ _, err := Assemble(AssembleRequest{RepoRoot: root, Position: PositionWidening, Target: "HEAD"})
+ if err == nil {
+ t.Fatalf("assemble with %s symlinked out of the checkout returned nil; it must refuse", level)
+ }
+ if !errors.Is(err, fsutil.ErrNotRealDir) {
+ t.Errorf("assemble refused for the wrong reason: %v", err)
+ }
+ if entries, _ := os.ReadDir(outside); len(entries) != 0 {
+ t.Errorf("assemble wrote %d entr(y|ies) at the link's target outside the checkout", len(entries))
+ }
+ })
+ }
+}
From dc10dd14832b7373ddcf5892fcacbdc883238cc4 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 22:43:33 +0100
Subject: [PATCH 23/84] =?UTF-8?q?chore:=20resolve=20iss-2609260948440803?=
=?UTF-8?q?=20=E2=80=94=20the=20local-tier=20run=20logs=20are=20proved=20b?=
=?UTF-8?q?efore=20they=20are=20written?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves: iss-2609260948440803
Assisted-by: Claude:claude-opus-5-5
---
...-tier-writes-by-path-memory-lint-writes-its-run-log.md | 8 ++++++++
1 file changed, 8 insertions(+)
rename .abcd/work/issues/{open => resolved}/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md (65%)
diff --git a/.abcd/work/issues/open/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md b/.abcd/work/issues/resolved/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md
similarity index 65%
rename from .abcd/work/issues/open/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md
rename to .abcd/work/issues/resolved/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md
index d96cc058f..a5fa87822 100644
--- a/.abcd/work/issues/open/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md
+++ b/.abcd/work/issues/resolved/iss-2609260948440803-local-tier-writes-by-path-memory-lint-writes-its-run-log.md
@@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review3-history item 6"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/memory/lint.go"
+resolution: "memory lint creates its run log through fsutil.CreateRunDir, which proves every level from the checkout root down with EnsureRealDirAll and creates the run directory exclusively, and writes both reports through fsutil.OpenRealDir with WriteFileAtomicInRoot. The sweep fixed the two siblings of the same shape (the issue-drift receipt and the reading assembler's in-repo run directory); launch's pre-flight report, intent audit's review writes, history's moveFile, ahoy's statusline step, the reading ingest stage, banlist and mode already prove or contain the tier."
+impact: fix
+resolved_by:
+ commit: "7d5785bef"
---
Local-tier writes by path: memory lint writes its run-log report by path into the local tier and follows a symlinked ancestor out of the checkout. Lint (internal/core/memory/lint.go, lintReportDir and the write after it) joins .abcd/.work.local/logs/memory/lint- onto the repo root, os.MkdirAll-s it, and writes report.json and report.md with fsutil.WriteFileAtomic by path. Nothing vets .abcd/.work.local or logs/ first, and a committed symlink beats .gitignore (git add -f), so a checkout that ships .abcd/.work.local as a symlink gets the directory chain and both reports created at the link's target: probed at 35d5cf5f with .abcd/.work.local linked to a directory outside the repo, Lint returned nil and logs/memory/lint-/report.json and report.md were written in the outside directory. The contained pattern for this same tier already exists: mode.SetAt (internal/core/mode/store.go) opens an os.Root on the checkout, root.Lstat-refuses a .abcd/.work.local that is not a real directory, and writes with fsutil.WriteFileAtomicInRoot. Two sites the review named alongside were checked at 35d5cf5f and are NOT in this class: intent/audit.go's review request and dead-letter writes vet .abcd/.work.local/reviews level by level with fsutil.EnsureRealDirAll, and history/location.go's moveFile writes under a chain Resolve proved real with fsutil.EnsureRealDir, so both refuse a symlinked ancestor; each keeps only a vet-by-path-then-write-by-path swap window.
+
+## Grounds
+
+- pursued: a checkout whose .abcd/.work.local, logs/ or logs/memory/ is a symlink out of the tree makes lint refuse with ErrNotRealDir and leaves the link target empty, while a real partly present tier still receives both reports; a report or directory appearing at the link target would show it wrong
From b156bb1bdf71913dfafe44ac412cb944590e0ae5 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 23:00:19 +0100
Subject: [PATCH 24/84] fix(lifeboat): one render discipline for every markdown
file the lifeboat writes
The memory renderers were fixed for iss-2609020539188868; the lifeboat
half was not. The review rendered a finding id through Sanitize alone and
wrapped a severity in its own brackets, the press-release subhead was
wrapped in the render's own emphasis, a principle, body or quote was
written as a bare paragraph with no leading-marker escape, and the packed
brief section docs listed source paths (hostile filenames included)
through Sanitize alone.
mdrender.go holds the one discipline every lifeboat markdown renderer now
goes through: mdInline cleans an untrusted field with termsafe.CleanProse
(a no-op on a field its ingest already cleaned); mdCode sets a value off
with termsafe.CodeSpan where a delimiter is wanted (severity, finding id,
evidence refs, source paths); mdBlock escapes the leading marker of a
value that begins a block. No renderer wraps a cleaned value in a
delimiter of its own: the severity is a code span, the subhead is its own
paragraph, and the mode line is a plain paragraph. The tests drive each
renderer directly with comment openers, script tags, link syntax,
bracket-closing severities and every block-marker lead.
Refs: iss-2609251355497247, iss-2609020539188868
Assisted-by: Claude:claude-opus-5-5
---
internal/core/lifeboat/mdrender.go | 78 ++++++++++++++
internal/core/lifeboat/mdrender_test.go | 102 ++++++++++++++++++
internal/core/lifeboat/plan.go | 6 +-
.../core/lifeboat/synthesis_pressrelease.go | 25 +++--
.../core/lifeboat/synthesis_principles.go | 27 +++--
internal/core/lifeboat/synthesis_review.go | 24 +++--
6 files changed, 228 insertions(+), 34 deletions(-)
create mode 100644 internal/core/lifeboat/mdrender.go
create mode 100644 internal/core/lifeboat/mdrender_test.go
diff --git a/internal/core/lifeboat/mdrender.go b/internal/core/lifeboat/mdrender.go
new file mode 100644
index 000000000..359004fbf
--- /dev/null
+++ b/internal/core/lifeboat/mdrender.go
@@ -0,0 +1,78 @@
+package lifeboat
+
+import (
+ "strings"
+
+ "github.com/intentdriven/abcd/internal/termsafe"
+)
+
+// mdrender.go — the one render discipline for every markdown file the lifeboat
+// writes (principles.md, press-release.md, review/review-.md and the
+// packed brief section docs). It is the discipline the memory renderers got for
+// iss-2609020539188868, applied to the lifeboat half (iss-2609251355497247):
+//
+// - every untrusted field on a markdown line goes through the file-write
+// cleaner, termsafe.CleanProse, never Sanitize alone — Sanitize defangs a
+// terminal and leaves an HTML comment opener or link syntax live;
+// - no renderer wraps a cleaned value in a delimiter of its own (brackets,
+// emphasis, backticks): termsafe's guarantees hold over the exact string it
+// returned, and a wrapper the value can close parses a different string;
+// where a delimiter is wanted, termsafe.CodeSpan picks one the value cannot
+// close, without altering the value's bytes;
+// - a value that begins a block has its leading marker escaped, so a field
+// cannot turn itself into a heading, a list, a quote, a fence or a table.
+//
+// Re-cleaning a field its ingest already cleaned is a no-op (the cleaner is
+// idempotent), so this costs a well-formed record nothing and holds for any
+// field that reaches a renderer by another route.
+
+// maxMDFieldBytes is the render-time cap: the largest any lifeboat field is
+// cleaned to at ingest (the press-release body), so the render never cuts a
+// field its ingest kept.
+const maxMDFieldBytes = maxPressReleaseBodyBytes
+
+// mdInline is an untrusted field placed mid-line: cleaned, nothing added.
+func mdInline(s string) string { return termsafe.CleanProse(s, maxMDFieldBytes) }
+
+// mdCode is an untrusted field set off as a code span whose fence the value
+// cannot close. An empty value renders as nothing.
+func mdCode(s string) string { return termsafe.CodeSpan(mdInline(s)) }
+
+// mdCodeList renders refs as comma-separated code spans.
+func mdCodeList(refs []string) string {
+ out := make([]string, 0, len(refs))
+ for _, r := range refs {
+ if c := mdCode(r); c != "" {
+ out = append(out, c)
+ }
+ }
+ return strings.Join(out, ", ")
+}
+
+// mdBlock is an untrusted field that begins a block (a paragraph, a quote's
+// first line): cleaned, then its leading marker escaped.
+func mdBlock(s string) string { return escapeLeadingMarker(mdInline(s)) }
+
+// escapeLeadingMarker backslash-escapes the character that would make s open a
+// block construct rather than a paragraph: an ATX heading (#), a bullet (- * +),
+// a block quote (>), a fence (` ~), a table row (|), a thematic break or setext
+// underline (- _ * =), raw HTML (<), or an ordered-list marker (digits then . or
+// )). CommonMark renders a backslash-escaped ASCII punctuation character as the
+// character itself, so the reader sees the value's text unchanged.
+func escapeLeadingMarker(s string) string {
+ if s == "" {
+ return s
+ }
+ switch s[0] {
+ case '#', '-', '*', '+', '>', '`', '~', '|', '=', '_', '<':
+ return `\` + s
+ }
+ digits := 0
+ for digits < len(s) && digits < 10 && s[digits] >= '0' && s[digits] <= '9' {
+ digits++
+ }
+ if digits > 0 && digits < len(s) && (s[digits] == '.' || s[digits] == ')') {
+ return s[:digits] + `\` + s[digits:]
+ }
+ return s
+}
diff --git a/internal/core/lifeboat/mdrender_test.go b/internal/core/lifeboat/mdrender_test.go
new file mode 100644
index 000000000..cefe190d9
--- /dev/null
+++ b/internal/core/lifeboat/mdrender_test.go
@@ -0,0 +1,102 @@
+package lifeboat
+
+import (
+ "strings"
+ "testing"
+)
+
+// The lifeboat half of iss-2609020539188868 (iss-2609251355497247): every
+// untrusted field on a line of a markdown file the lifeboat writes goes through
+// the file-write cleaner (termsafe.CleanProse), never Sanitize alone; no renderer
+// wraps a cleaned value in a delimiter of its own (a code span through
+// termsafe.CodeSpan where one is wanted); and a value that opens a block is
+// escaped so its leading marker cannot turn it into a heading, list, quote or
+// fence. These tests drive each renderer with the hostile value directly, so
+// the render's discipline is proved on its own and not only through whatever an
+// ingest happened to clean first.
+
+// rawMarkdownHazards are the constructs a cleaned value must never carry live.
+var rawMarkdownHazards = []string{"