From 555f6ed37e0e35a3e7c608a60db07a3c8877f6c7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:26:41 +0100 Subject: [PATCH 01/14] docs: plan itd-102 and write spc-19 repo-identity spec The 2026-07-27 grill settled the design; the spec records the settlements plus the mechanism (fixed-bullet markdown identity block, config records only its location, warn-tier positioning check with re-render as a proposed diff, iss-143 as acceptance corpus). Fourth iss-80 mint collision: renumbered to spc-19. Assisted-by: Claude:claude-fable-5 --- ...me-thing-about-itself-everywhere-becaus.md | 4 +- ...me-thing-about-itself-everywhere-becaus.md | 102 ++++++++++++++++++ .abcd/work/DECISIONS.md | 1 + 3 files changed, 105 insertions(+), 2 deletions(-) rename .abcd/development/intents/{drafts => planned}/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md (99%) create mode 100644 .abcd/development/specs/open/spc-19-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md diff --git a/.abcd/development/intents/drafts/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md b/.abcd/development/intents/planned/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md similarity index 99% rename from .abcd/development/intents/drafts/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md rename to .abcd/development/intents/planned/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md index 100f758..05cbcce 100644 --- a/.abcd/development/intents/drafts/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md +++ b/.abcd/development/intents/planned/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md @@ -1,8 +1,8 @@ --- id: itd-102 slug: your-repo-says-the-same-thing-about-itself-everywhere-becaus -spec_id: null -kind: null +spec_id: spc-19 +kind: standalone suggested_kind: null reclassification_history: [] builds_on: [] diff --git a/.abcd/development/specs/open/spc-19-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md b/.abcd/development/specs/open/spc-19-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md new file mode 100644 index 0000000..80ffeb1 --- /dev/null +++ b/.abcd/development/specs/open/spc-19-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md @@ -0,0 +1,102 @@ +--- +id: spc-19 +slug: your-repo-says-the-same-thing-about-itself-everywhere-becaus +intent: itd-102 +--- +# your-repo-says-the-same-thing-about-itself-everywhere-becaus + +## Summary + +spc-19 delivers itd-102's repo-identity system: one canonical, parseable +markdown identity block (title, tagline, pitch), an onboarding interview that +writes it, and a deterministic positioning check that holds every registered +surface to it — highlighting the exact drifted line, warn-tier by default, +and never rewriting anything autonomously. The design decisions below were +settled by the 2026-07-27 grill; this spec records them together with the +mechanism — it does not reopen them. + +## Settled constraints (from the grill) + +- **The identity home is a parseable markdown block, not a structured file.** + Markdown stays the single source of truth; the repo's committed abcd + configuration records only where the block lives. +- **The check is warn-tier by default** — highlight, never gate — and + per-repo upgradeable to blocker. **Autonomous rewriting is permanently + out**: re-rendering from the block is always a proposed diff a human + adopts. + +## Mechanism + +### Identity block + +The block is three fixed bullets under a recognisable heading, exactly the +shape the abcd-cli brief already carries (`.abcd/development/brief/01-product/README.md`, "Identity (canonical)"): + +- `- **Title:** …` (required) +- `- **Tagline:** …` (required) +- `- **Pitch:** …` (optional at onboarding; title and tagline are required) + +The parser accepts a multi-line pitch bullet. The block's location — file +path plus heading — is recorded in the repo's committed abcd configuration +alongside the other check configuration; abcd-cli's own configuration points +at the existing brief section unchanged (AC 4). + +### Onboarding interview + +The prepare flow asks once: title, tagline, pitch (pitch may be skipped). +The host conducts the interview (the prepare skill carries the wording); the +binary scaffolds the block into the recorded location and the pointer into +the configuration. A repo that already has a block is never re-interviewed — +onboarding detects and registers it instead. + +### Positioning check + +A deterministic check, run by the audit verb, compares each registered +surface against the block: + +- **Default registered surfaces (three):** the README strapline, the + plugin/package manifest description field, and the conventions file's + (AGENTS.md) opening line. Further surfaces are registrable per-repo in the + same configuration; none are registered silently. +- A diverging surface yields a warn-tier finding naming the file and the + exact drifted line, and quoting the canonical line it should carry. + Per-repo configuration may upgrade the family to blocker. +- **Acceptance corpus:** iss-143's three-variant tagline drift — the + recorded real instance this check exists to catch. The check's fixtures + reproduce that drift shape and must all be caught. + +### Re-render as a proposal + +`abcd identity render` (verb name final at implementation; the behaviour is +the contract) prints the proposed corrected surface content as a diff for +the human to adopt. It writes nothing. A deliberate identity change is an +edit to the block itself, after which the same proposal flow chases the +surfaces. + +### Wiring + +Both planes at delivery: the audit verb's check and the identity verbs reach +the CLI and the plugin markdown surface; the prepare skill gains the +interview step. abcd-cli's own audit runs the check against its brief block +and passes clean (or its findings are real drift, fixed by re-render or +recorded change — never by weakening the check). + +## Acceptance-criteria mapping + +- AC 1 (interview lands a parseable fixed-shape block; config records the + location; markdown is the source of truth) → Identity block + Onboarding + interview. +- AC 2 (divergence yields a warn finding naming the exact line; per-repo + upgrade to blocker) → Positioning check. +- AC 3 (never rewrites autonomously; re-render is a proposed diff) → + Re-render as a proposal. +- AC 4 (abcd-cli points at the existing brief Identity section unchanged; + iss-143 is the acceptance corpus) → Identity block + Positioning check. + +## Out of scope + +- Registering surfaces beyond the canonical three by default (per-repo + addition exists; defaults stay at three). +- Any autonomous write to a rendered surface, under any flag. +- Interview wording beyond the prepare skill's text (host-side, adjustable + without a binary change). diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 7cc9a06..008bed8 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -862,3 +862,4 @@ parallel-agent merge contention bites. - 2026-07-27 — ADOPTED the canonical tagline: "A host-agnostic configuration layer for intent-driven development." Canonical identity home is the brief product chapter (01-product README, Identity section: title / tagline / pitch); README strapline, plugin manifest description, and AGENTS.md opening render from it. README's former strapline ("An opinionated, intent-driven development framework for product thinkers") is retired as a surface line; the product-thinker framing lives on in the README body. Rejected wordings: "for agent harnesses" (object shift, host/harness redundancy, plural over-promise), "over any agent harness" (drops the domain). Resolves iss-143; itd-102 generalises the drift check for managed repos. - 2026-07-27 — itd-93 parity tension resolved: Branch A. The scaffold template is the single source for release.yml/auto-release.yml; abcd-cli's live workflows are regenerated from it; the only sanctioned live diff is the additive workflow_dispatch rehearsal (trigger + non-publishing dry-run job). Rejected: substitution-gating the rehearsal off for abcd (weakens the parity guarantee on the one novel component). - 2026-07-27 — GRILLED itd-101/102/103/104 to settlement (six decisions, each now in its intent's Grill Settlements + acceptance criteria): itd-103 guard fails open loudly, blocker/warn tiers with committed-only overrides, shell-token-aware command-position matching with an itd-81-style TNR-floored corpus; itd-101 refresh is manual-with-nagging (scheduled CI later, own sign-off), staleness warns at 180d and blocks releases at 365d, human verifications age on the same clock; itd-102 identity lives in a parseable markdown block (config points at it), check is warn-tier and never rewrites; itd-104 ideate is an optional verb, never a pre-capture gate, with a fresh-context off-policy adversary. All four drafts are grill-settled and await intent plan. +- 2026-07-28: itd-102 spec minted as spc-16 (fourth live iss-80 instance this run); renumbered to spc-19 by hand — this branch deliberately skips 16-18 (16 on #156, 17 on the itd-101 branch, 18 on #157). From 8933a2867ce17f626a8f64fbe8b2df04ed15a683 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:51:54 +0100 Subject: [PATCH 02/14] refactor: promote validRelPath to fsutil.ValidRelPath The lexical repo-relative-path guard lived unexported inside the lifeboat packer, but it is the general primitive any caller needs before joining a path that arrived as data onto a trusted root. Move it to the canonical path home (internal/fsutil) so a second caller extends the primitive rather than copying it; lifeboat's validRelPath now delegates. Behaviour-preserving: the implementation is unchanged, and the exported form gains its own table test. Assisted-by: Claude:claude-opus-5 --- internal/core/lifeboat/pack.go | 27 ++++----------------------- internal/fsutil/paths.go | 29 +++++++++++++++++++++++++++++ internal/fsutil/paths_test.go | 16 ++++++++++++++++ 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/internal/core/lifeboat/pack.go b/internal/core/lifeboat/pack.go index e24cc94..eab7e16 100644 --- a/internal/core/lifeboat/pack.go +++ b/internal/core/lifeboat/pack.go @@ -268,29 +268,10 @@ func isAbcdLifeboat(dir string) bool { return prov.SchemaVersion >= 1 && prov.ManifestSHA256 != "" } -// validRelPath reports whether p is a safe destination path to write: non-empty, -// relative, already cleaned, every segment a real name (not "", ".", ".."), and -// free of control characters. os.Root enforces containment too; this refuses -// early with a legible message. -func validRelPath(p string) bool { - if p == "" || path.IsAbs(p) || strings.HasPrefix(p, "/") { - return false - } - if p != path.Clean(p) { - return false - } - for _, seg := range strings.Split(p, "/") { - if seg == "" || seg == "." || seg == ".." { - return false - } - for _, r := range seg { - if r < 0x20 || r == 0x7f { - return false - } - } - } - return true -} +// validRelPath reports whether p is a safe destination path to write. It is the +// canonical fsutil guard: os.Root enforces containment too; this refuses early +// with a legible message. +func validRelPath(p string) bool { return fsutil.ValidRelPath(p) } // writeLifeboat writes every planned file into a fresh staging directory beside // dest (so the final rename stays on one filesystem), then swaps staging into diff --git a/internal/fsutil/paths.go b/internal/fsutil/paths.go index 57223a4..892e5a4 100644 --- a/internal/fsutil/paths.go +++ b/internal/fsutil/paths.go @@ -5,10 +5,39 @@ import ( "fmt" "io" "os" + "path" "path/filepath" + "strings" "syscall" ) +// ValidRelPath reports whether p is a safe repo-relative slash path: non-empty, +// not absolute, already clean, and free of empty, ".", ".." or control-character +// segments. It is the canonical lexical guard for a path that arrives as data — +// a committed configuration value, a packed lifeboat's manifest entry — before +// it is joined onto a trusted root. Lexical only: it says nothing about +// symlinks, so a caller that then follows the path still needs the guarded +// read/write primitives. +func ValidRelPath(p string) bool { + if p == "" || path.IsAbs(p) || strings.HasPrefix(p, "/") { + return false + } + if p != path.Clean(p) { + return false + } + for _, seg := range strings.Split(p, "/") { + if seg == "" || seg == "." || seg == ".." { + return false + } + for _, r := range seg { + if r < 0x20 || r == 0x7f { + return false + } + } + } + return true +} + // RepoRel renders target as a path relative to base — the repo root, or the // working directory — so machine output never carries an absolute // developer-identity path (iss-81). A target outside base yields a "../…" form, diff --git a/internal/fsutil/paths_test.go b/internal/fsutil/paths_test.go index c3977bc..8d97e61 100644 --- a/internal/fsutil/paths_test.go +++ b/internal/fsutil/paths_test.go @@ -8,6 +8,22 @@ import ( "github.com/REPPL/abcd-cli/internal/fsutil" ) +func TestValidRelPath(t *testing.T) { + for _, p := range []string{"README.md", "a/b/c.json", ".abcd/development/brief/01-product/README.md"} { + if !fsutil.ValidRelPath(p) { + t.Errorf("ValidRelPath(%q) = false, want true", p) + } + } + for _, p := range []string{ + "", "/etc/passwd", "../outside", "a/../../b", "./a", "a//b", "a/", "a/./b", + "a\x00b", "a\nb", + } { + if fsutil.ValidRelPath(p) { + t.Errorf("ValidRelPath(%q) = true, want false", p) + } + } +} + func TestExists(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "present.txt") From ef602a7d05214f7ccb3e2b44cf9048f126b77a72 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:56:26 +0100 Subject: [PATCH 03/14] feat: parse the canonical identity block and check surfaces against it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repository's positioning fragments silently: the README strapline, the manifest description, and the conventions file's opening are edited at different moments until three surfaces tell three stories (iss-143). This adds the core primitive that stops that. internal/core/positioning parses one canonical markdown identity block — the fixed `- **Title:** / **Tagline:** / **Pitch:**` bullets under a recorded heading, title and tagline required, a wrapped pitch joined — and compares every registered surface against it. Markdown stays the single source of truth; .abcd/positioning.json records only where the block lives, how loudly the family reports, and which surfaces render from it. The registry is data: a surface names candidate files (first present wins, so one entry covers several manifest formats), a locator (a capture-group regexp or a top-level JSON field), and the block fields it must carry. Comparison normalises markup, dashes, wrapping, and case, so a bolded or line-wrapped tagline is not drift while a reworded one is. Every registry value is validated before use, and every file read goes through the guarded primitive. The iss-143 three-variant drift is the acceptance corpus, one test per variant plus all three together. Assisted-by: Claude:claude-opus-5 --- internal/core/positioning/block.go | 211 +++++++++++++++ internal/core/positioning/block_test.go | 174 ++++++++++++ internal/core/positioning/check.go | 245 +++++++++++++++++ internal/core/positioning/check_test.go | 323 +++++++++++++++++++++++ internal/core/positioning/config.go | 277 +++++++++++++++++++ internal/core/positioning/config_test.go | 165 ++++++++++++ 6 files changed, 1395 insertions(+) create mode 100644 internal/core/positioning/block.go create mode 100644 internal/core/positioning/block_test.go create mode 100644 internal/core/positioning/check.go create mode 100644 internal/core/positioning/check_test.go create mode 100644 internal/core/positioning/config.go create mode 100644 internal/core/positioning/config_test.go diff --git a/internal/core/positioning/block.go b/internal/core/positioning/block.go new file mode 100644 index 0000000..7e249d0 --- /dev/null +++ b/internal/core/positioning/block.go @@ -0,0 +1,211 @@ +// Package positioning holds a repository's canonical self-description — its +// title, tagline, and elevator pitch — and the deterministic check that every +// rendered surface still says it. +// +// The canonical home is a markdown block in the repo's own record (markdown +// stays the single source of truth); the committed configuration records only +// where that block lives and which surfaces render from it. The check is +// advisory by default and never rewrites a surface: a re-render is always a +// proposed diff a human adopts (spc-19, itd-102). +// +// This is repo POSITIONING, not commit-author identity: the git author gate +// pinned in .abcd/config/identity.json lives in internal/core/identity and +// shares nothing with this package but the English word. +package positioning + +import ( + "errors" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/REPPL/abcd-cli/internal/fsutil" +) + +// maxBlockFileBytes caps a guarded read of the block's host file. The block +// lives in a committed record file; a megabyte is far past any real one and +// stops a device or runaway file being read unbounded. +const maxBlockFileBytes = 1 << 20 + +// Parse failures, as sentinels so a caller can distinguish "the repo has not +// adopted a block" from "the block is malformed" without string matching. +var ( + // ErrBlockFileMissing: the configured file does not exist (or is not a + // regular file). + ErrBlockFileMissing = errors.New("identity block file not found") + // ErrHeadingMissing: the file exists but carries no such heading. + ErrHeadingMissing = errors.New("identity block heading not found") + // ErrBulletMissing: the heading exists but a required bullet (title or + // tagline) is absent or empty. + ErrBulletMissing = errors.New("identity block is missing a required bullet") + // ErrBadLocation: the configured location is not a safe repo-relative path. + ErrBadLocation = errors.New("identity block location is not a repo-relative path") +) + +// BlockLocation is where the identity block lives: a repo-relative markdown +// file and the heading the block sits under. It is committed configuration, not +// derived, so both fields are validated before use. +type BlockLocation struct { + File string `json:"file"` + Heading string `json:"heading"` +} + +// Block is a repository's canonical self-description. Title and Tagline are +// required; Pitch is optional at onboarding. File and Line cite where it was +// parsed from so a finding can point back at the canon. +type Block struct { + Title string `json:"title"` + Tagline string `json:"tagline"` + Pitch string `json:"pitch,omitempty"` + File string `json:"file"` + Line int `json:"line"` +} + +// Field returns a block field by its lower-case name (the vocabulary a surface's +// requires/template use), reporting whether the name is one of the three. +func (b Block) Field(name string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "title": + return b.Title, true + case "tagline": + return b.Tagline, true + case "pitch": + return b.Pitch, true + } + return "", false +} + +// headingRe matches an ATX heading and captures its level and text. +var headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*#*\s*$`) + +// bulletRe matches the fixed identity bullet shape — `- **Label:** value` (a `-` +// or `*` marker, up to three leading spaces) — capturing the label and the +// value. The value may be empty when the bullet wraps onto continuation lines. +var bulletRe = regexp.MustCompile(`^ {0,3}[-*]\s+\*\*([A-Za-z]+):\*\*\s*(.*)$`) + +// ParseBlock reads the identity block at loc under root. The heading match is +// case-insensitive and level-agnostic; the block runs to the next heading of the +// same or shallower level (or end of file). A bullet's value may wrap onto +// indented continuation lines, which are joined with a single space. +// +// Title and Tagline are required; Pitch is optional. Every failure mode is a +// sentinel-wrapped error, never a zero Block with a nil error — a caller must +// not read "no block" as "the block says nothing". +func ParseBlock(root string, loc BlockLocation) (Block, error) { + if !fsutil.ValidRelPath(loc.File) { + return Block{}, fmt.Errorf("%w: %q", ErrBadLocation, loc.File) + } + heading := strings.TrimSpace(loc.Heading) + if heading == "" { + return Block{}, fmt.Errorf("%w: heading is empty", ErrBadLocation) + } + + data, err := fsutil.ReadGuarded(filepath.Join(root, filepath.FromSlash(loc.File)), maxBlockFileBytes) + if err != nil { + // A missing, symlinked, or oversize block file is all one thing to a + // caller — the canon could not be read where the configuration said it + // was. The underlying reason rides along for the human. + return Block{}, fmt.Errorf("%w: %s: %s", ErrBlockFileMissing, loc.File, err) + } + + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + start, level := findHeading(lines, heading) + if start < 0 { + return Block{}, fmt.Errorf("%w: %q in %s", ErrHeadingMissing, heading, loc.File) + } + + b := Block{File: loc.File, Line: start + 1} + for _, bl := range collectBullets(lines, start+1, level) { + switch strings.ToLower(bl.label) { + case "title": + b.Title = bl.value + case "tagline": + b.Tagline = bl.value + case "pitch": + b.Pitch = bl.value + } + } + for _, req := range []struct{ name, value string }{{"Title", b.Title}, {"Tagline", b.Tagline}} { + if req.value == "" { + return Block{}, fmt.Errorf("%w: **%s:** under %q in %s", ErrBulletMissing, req.name, heading, loc.File) + } + } + return b, nil +} + +// findHeading returns the index of the first heading whose text equals want +// (case-insensitively, ignoring surrounding space) and its level, or -1. +func findHeading(lines []string, want string) (idx, level int) { + want = strings.ToLower(want) + for i, line := range lines { + m := headingRe.FindStringSubmatch(line) + if m == nil { + continue + } + if strings.ToLower(strings.TrimSpace(m[2])) == want { + return i, len(m[1]) + } + } + return -1, 0 +} + +type bullet struct{ label, value string } + +// collectBullets gathers the identity bullets between the heading at from-1 and +// the next heading of level <= level (or end of file), folding each bullet's +// indented continuation lines into its value. +func collectBullets(lines []string, from, level int) []bullet { + var out []bullet + for i := from; i < len(lines); i++ { + line := lines[i] + if m := headingRe.FindStringSubmatch(line); m != nil && len(m[1]) <= level { + break + } + m := bulletRe.FindStringSubmatch(line) + if m == nil { + continue + } + parts := []string{strings.TrimSpace(m[2])} + // Continuations: indented, non-blank lines that are not themselves a + // list item or a heading. A blank line ends the bullet. + for j := i + 1; j < len(lines); j++ { + next := lines[j] + if strings.TrimSpace(next) == "" || !isIndentedContinuation(next) { + break + } + parts = append(parts, strings.TrimSpace(next)) + i = j + } + out = append(out, bullet{label: m[1], value: strings.TrimSpace(strings.Join(nonEmpty(parts), " "))}) + } + return out +} + +// isIndentedContinuation reports whether line continues the preceding bullet: +// it starts with whitespace and does not open a new list item or heading. +func isIndentedContinuation(line string) bool { + if line == "" || !(line[0] == ' ' || line[0] == '\t') { + return false + } + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "#") { + return false + } + if len(t) >= 2 && (t[0] == '-' || t[0] == '*' || t[0] == '+') && (t[1] == ' ' || t[1] == '\t') { + return false + } + return true +} + +// nonEmpty drops empty strings so a label-only bullet with a wrapped value does +// not gain a leading space. +func nonEmpty(in []string) []string { + out := in[:0:0] + for _, s := range in { + if s != "" { + out = append(out, s) + } + } + return out +} diff --git a/internal/core/positioning/block_test.go b/internal/core/positioning/block_test.go new file mode 100644 index 0000000..3631fbf --- /dev/null +++ b/internal/core/positioning/block_test.go @@ -0,0 +1,174 @@ +package positioning + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// writeRepo materialises a throwaway repo tree from a path->content map and +// returns its root. +func writeRepo(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for rel, content := range files { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +const briefLike = `# Product + +## Identity (canonical) + +The single recorded home for how the project names itself. + +- **Title:** abcd — Agent-Based Configuration for Development +- **Tagline:** A host-agnostic configuration layer for intent-driven development. +- **Pitch:** A single Go binary that carries the why from idea to shipped + reality, usable as a plugin in compatible agent harnesses. + +## Something else + +- **Title:** not the identity block +` + +func TestParseBlockReadsTheThreeBullets(t *testing.T) { + root := writeRepo(t, map[string]string{"brief.md": briefLike}) + + got, err := ParseBlock(root, BlockLocation{File: "brief.md", Heading: "Identity (canonical)"}) + if err != nil { + t.Fatalf("ParseBlock: %v", err) + } + if want := "abcd — Agent-Based Configuration for Development"; got.Title != want { + t.Errorf("Title = %q, want %q", got.Title, want) + } + if want := "A host-agnostic configuration layer for intent-driven development."; got.Tagline != want { + t.Errorf("Tagline = %q, want %q", got.Tagline, want) + } + // The pitch bullet wraps across two lines; the parser joins the continuation. + if want := "A single Go binary that carries the why from idea to shipped reality, usable as a plugin in compatible agent harnesses."; got.Pitch != want { + t.Errorf("Pitch = %q, want %q", got.Pitch, want) + } + if got.File != "brief.md" { + t.Errorf("File = %q", got.File) + } + if got.Line != 3 { + t.Errorf("Line = %d, want 3 (the heading line)", got.Line) + } +} + +func TestParseBlockStopsAtTheNextHeading(t *testing.T) { + root := writeRepo(t, map[string]string{"brief.md": briefLike}) + got, err := ParseBlock(root, BlockLocation{File: "brief.md", Heading: "Identity (canonical)"}) + if err != nil { + t.Fatalf("ParseBlock: %v", err) + } + if got.Title == "not the identity block" { + t.Fatal("parser bled past the section boundary into the next heading") + } +} + +func TestParseBlockPitchIsOptional(t *testing.T) { + root := writeRepo(t, map[string]string{"brief.md": `## Identity + +- **Title:** Widget +- **Tagline:** Widgets, but sharper. +`}) + got, err := ParseBlock(root, BlockLocation{File: "brief.md", Heading: "Identity"}) + if err != nil { + t.Fatalf("ParseBlock: %v", err) + } + if got.Pitch != "" { + t.Errorf("Pitch = %q, want empty", got.Pitch) + } +} + +func TestParseBlockHeadingMatchIsCaseInsensitiveAndLevelAgnostic(t *testing.T) { + root := writeRepo(t, map[string]string{"brief.md": `#### IDENTITY (Canonical) + +- **Title:** Widget +- **Tagline:** Widgets, but sharper. +`}) + if _, err := ParseBlock(root, BlockLocation{File: "brief.md", Heading: "identity (canonical)"}); err != nil { + t.Fatalf("ParseBlock: %v", err) + } +} + +func TestParseBlockTypedErrors(t *testing.T) { + cases := []struct { + name string + files map[string]string + loc BlockLocation + wantErr error + }{ + { + name: "file absent", + files: map[string]string{"other.md": "# x\n"}, + loc: BlockLocation{File: "brief.md", Heading: "Identity"}, + wantErr: ErrBlockFileMissing, + }, + { + name: "heading absent", + files: map[string]string{"brief.md": "# Product\n\nnothing here\n"}, + loc: BlockLocation{File: "brief.md", Heading: "Identity"}, + wantErr: ErrHeadingMissing, + }, + { + name: "title bullet missing", + files: map[string]string{"brief.md": "## Identity\n\n- **Tagline:** t\n"}, + loc: BlockLocation{File: "brief.md", Heading: "Identity"}, + wantErr: ErrBulletMissing, + }, + { + name: "tagline bullet missing", + files: map[string]string{"brief.md": "## Identity\n\n- **Title:** t\n"}, + loc: BlockLocation{File: "brief.md", Heading: "Identity"}, + wantErr: ErrBulletMissing, + }, + { + name: "title bullet present but empty", + files: map[string]string{"brief.md": "## Identity\n\n- **Title:**\n- **Tagline:** t\n"}, + loc: BlockLocation{File: "brief.md", Heading: "Identity"}, + wantErr: ErrBulletMissing, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := writeRepo(t, tc.files) + _, err := ParseBlock(root, tc.loc) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + }) + } +} + +func TestParseBlockRefusesEscapingLocation(t *testing.T) { + root := writeRepo(t, map[string]string{"brief.md": briefLike}) + for _, bad := range []string{"../outside.md", "/etc/passwd", ""} { + if _, err := ParseBlock(root, BlockLocation{File: bad, Heading: "Identity"}); err == nil { + t.Fatalf("ParseBlock(%q) = nil error, want a refusal", bad) + } + } +} + +func TestBlockFieldLookup(t *testing.T) { + b := Block{Title: "T", Tagline: "TL", Pitch: "P"} + for field, want := range map[string]string{"title": "T", "tagline": "TL", "pitch": "P"} { + got, ok := b.Field(field) + if !ok || got != want { + t.Errorf("Field(%q) = %q,%v want %q,true", field, got, ok, want) + } + } + if _, ok := b.Field("nope"); ok { + t.Error("Field(\"nope\") reported ok") + } +} diff --git a/internal/core/positioning/check.go b/internal/core/positioning/check.go new file mode 100644 index 0000000..8250a9c --- /dev/null +++ b/internal/core/positioning/check.go @@ -0,0 +1,245 @@ +package positioning + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/REPPL/abcd-cli/internal/fsutil" +) + +// maxSurfaceBytes caps a guarded surface read. A README or manifest is small; +// the cap stops a device or runaway file being read unbounded. +const maxSurfaceBytes = 1 << 20 + +// Status is one surface's outcome. +type Status string + +const ( + // StatusOK: the surface carries every block field it declares. + StatusOK Status = "ok" + // StatusDrifted: the surface was located but no longer says what the block + // says. + StatusDrifted Status = "drifted" + // StatusAbsent: none of the surface's candidate files exists, so the + // surface is not present in this repo. Not a finding. + StatusAbsent Status = "absent" + // StatusUnlocatable: the file exists but the locator found nothing. A + // finding: a locator that stopped matching hides drift rather than proving + // its absence. + StatusUnlocatable Status = "unlocatable" +) + +// SurfaceResult is one surface's comparison against the block. +type SurfaceResult struct { + ID string `json:"id"` + Status Status `json:"status"` + // File is the candidate that was checked (empty when all were absent). + File string `json:"file,omitempty"` + // Line is the 1-based line the located text starts on. + Line int `json:"line,omitempty"` + // Found is the located text, verbatim — the exact drifted content. + Found string `json:"found,omitempty"` + // Missing names the block fields the surface no longer carries. + Missing []string `json:"missing,omitempty"` + // Canonical is what the block says this surface should carry. + Canonical string `json:"canonical,omitempty"` +} + +// Report is a whole positioning check: the block it was held to, the family +// severity, and one result per registered surface. +type Report struct { + Block Block `json:"block"` + Severity string `json:"severity"` + Surfaces []SurfaceResult `json:"surfaces"` +} + +// Drifted counts the surfaces that need attention — drifted or unlocatable. +func (r Report) Drifted() int { + n := 0 + for _, s := range r.Surfaces { + if s.Status == StatusDrifted || s.Status == StatusUnlocatable { + n++ + } + } + return n +} + +// Check parses the identity block and compares every registered surface against +// it. It reads only; nothing is written and nothing is proposed here. An +// unreadable or malformed block is an error, never an empty clean report — a +// check that cannot run must not read as a pass. +func Check(root string, cfg Config) (Report, error) { + if err := cfg.Validate(); err != nil { + return Report{}, err + } + block, err := ParseBlock(root, cfg.Block) + if err != nil { + return Report{}, err + } + rep := Report{Block: block, Severity: cfg.severity()} + for _, s := range cfg.surfaces() { + res, err := checkSurface(root, s, block) + if err != nil { + return Report{}, err + } + rep.Surfaces = append(rep.Surfaces, res) + } + return rep, nil +} + +// checkSurface resolves the surface's file, locates its self-description, and +// compares it to the block. +func checkSurface(root string, s Surface, b Block) (SurfaceResult, error) { + res := SurfaceResult{ID: s.ID, Status: StatusAbsent, Canonical: s.render(b)} + + rel, data, ok, err := readFirstPresent(root, s.Files) + if err != nil { + return SurfaceResult{}, err + } + if !ok { + return res, nil + } + res.File = rel + + found, line, located, err := s.locate(data) + if err != nil { + return SurfaceResult{}, fmt.Errorf("surface %q in %s: %w", s.ID, rel, err) + } + if !located { + res.Status = StatusUnlocatable + return res, nil + } + res.Found, res.Line = found, line + + hay := normalize(found) + for _, field := range s.Requires { + want, _ := b.Field(field) + if want == "" { + // An optional field the block does not carry cannot be required of + // a surface; skip it rather than fabricating drift. + continue + } + if !strings.Contains(hay, needle(want)) { + res.Missing = append(res.Missing, field) + } + } + res.Status = StatusOK + if len(res.Missing) > 0 { + res.Status = StatusDrifted + } + return res, nil +} + +// readFirstPresent returns the first candidate that exists, its repo-relative +// path, and its contents. Every read is guarded: a surface file in an arbitrary +// audited repo may be a symlink, a device, or enormous. +func readFirstPresent(root string, files []string) (rel string, data []byte, ok bool, err error) { + for _, f := range files { + if !fsutil.ValidRelPath(f) { + return "", nil, false, fmt.Errorf("%w: surface file %q is not a repo-relative path", ErrConfigInvalid, f) + } + b, rerr := fsutil.ReadGuarded(filepath.Join(root, filepath.FromSlash(f)), maxSurfaceBytes) + if rerr != nil { + if os.IsNotExist(rerr) { + continue + } + // A present-but-unreadable surface (symlinked, oversize, a device) + // is not an absence: report it rather than skipping it silently. + return f, nil, false, fmt.Errorf("reading surface %s: %w", f, rerr) + } + return f, b, true, nil + } + return "", nil, false, nil +} + +// locate extracts the surface's self-description and the 1-based line it starts +// on, reporting whether the locator matched at all. +func (s Surface) locate(data []byte) (found string, line int, ok bool, err error) { + text := strings.ReplaceAll(string(data), "\r\n", "\n") + switch s.Kind { + case KindJSONField: + var obj map[string]json.RawMessage + if uerr := json.Unmarshal([]byte(text), &obj); uerr != nil { + // A manifest that is not a JSON object cannot be located; that is a + // locator failure, not a parse crash for the whole audit. + return "", 0, false, nil + } + raw, present := obj[s.Field] + if !present { + return "", 0, false, nil + } + var v string + if uerr := json.Unmarshal(raw, &v); uerr != nil { + return "", 0, false, nil // present but not a string + } + return v, jsonFieldLine(text, s.Field), true, nil + case KindRegexp: + for _, p := range s.Patterns { + re, cerr := regexp.Compile(p) + if cerr != nil { + return "", 0, false, fmt.Errorf("pattern %q does not compile: %w", p, cerr) + } + loc := re.FindStringSubmatchIndex(text) + if loc == nil || loc[2] < 0 { + continue + } + return text[loc[2]:loc[3]], 1 + strings.Count(text[:loc[2]], "\n"), true, nil + } + return "", 0, false, nil + } + return "", 0, false, fmt.Errorf("%w: unknown surface kind %q", ErrConfigInvalid, s.Kind) +} + +// jsonFieldLine finds the 1-based line a top-level JSON key sits on, so a +// finding can cite file:line. It is a display aid over the already-parsed +// document: a miss yields 0 rather than a wrong line. +func jsonFieldLine(text, field string) int { + key := `"` + field + `"` + for i, l := range strings.Split(text, "\n") { + if strings.Contains(l, key) { + return i + 1 + } + } + return 0 +} + +var ( + // emphasisRe strips inline markdown emphasis and code markers, so a tagline + // bolded inside a sentence still reads as the tagline. + emphasisRe = regexp.MustCompile("[*_`]+") + // htmlTagRe strips inline HTML tags, so an anchor or span wrapped around + // part of a strapline does not count as drift. + htmlTagRe = regexp.MustCompile(`<[^>]*>`) + // spaceRe collapses every whitespace run, so line wrapping is invisible to + // the comparison. + spaceRe = regexp.MustCompile(`\s+`) + // dashRe folds the dash family and non-breaking spaces onto their plain + // forms, so typographic polish is not drift. + dashRe = strings.NewReplacer( + "—", "-", "–", "-", "‒", "-", "‑", "-", "−", "-", + " ", " ", "‘", "'", "’", "'", "“", `"`, "”", `"`, + ) +) + +// normalize reduces text to the form the comparison is made in: HTML and +// markdown markup stripped, dashes and quotes folded, whitespace collapsed, +// lower-cased. It is deliberately lossy — the check asks whether a surface still +// SAYS the canonical line, not whether it renders it byte for byte. +func normalize(s string) string { + s = htmlTagRe.ReplaceAllString(s, "") + s = emphasisRe.ReplaceAllString(s, "") + s = dashRe.Replace(s) + s = spaceRe.ReplaceAllString(s, " ") + return strings.ToLower(strings.TrimSpace(s)) +} + +// needle is a block field normalised for containment: terminal sentence +// punctuation is dropped so a tagline that ends a sentence in one surface and +// continues into a clause in another still matches. +func needle(field string) string { + return strings.TrimRight(normalize(field), " .!?;:,") +} diff --git a/internal/core/positioning/check_test.go b/internal/core/positioning/check_test.go new file mode 100644 index 0000000..daee679 --- /dev/null +++ b/internal/core/positioning/check_test.go @@ -0,0 +1,323 @@ +package positioning + +import ( + "strings" + "testing" +) + +// canonBrief is the identity block every fixture below is held to — the same +// three bullets abcd-cli's own brief carries. +const canonBrief = `## Identity (canonical) + +- **Title:** abcd — Agent-Based Configuration for Development +- **Tagline:** A host-agnostic configuration layer for intent-driven development. +- **Pitch:** A single Go binary that carries the why from idea to shipped + reality, usable as a plugin in compatible agent harnesses. +` + +// The three CLEAN surfaces: byte-for-byte the shapes abcd-cli ships today — +// an HTML strapline in a centred README header, a manifest description that +// carries tagline and pitch, and a conventions opening paragraph that wraps the +// tagline across two lines with markdown emphasis inside it. +const ( + cleanREADME = `
A host-agnostic configuration layer for intent-driven development.
+ +An opinionated, intent-driven development framework for product thinkers.
+ +(.*?)
`}, + Requires: []string{"tagline"}, Template: "{tagline}", + }, + { + ID: "manifest-description", Files: []string{"plugin.json"}, Kind: KindJSONField, + Field: "description", Requires: []string{"tagline", "pitch"}, + Template: "{tagline} {pitch}", + }, + { + ID: "conventions-opening", Files: []string{"AGENTS.md"}, Kind: KindRegexp, + Patterns: []string{`(?s)\n+(.*?)(?:\n\n|\z)`}, + Requires: []string{"tagline"}, Template: "{tagline}", + }, + }, + } +} + +func cleanRepo(t *testing.T) string { + t.Helper() + return writeRepo(t, map[string]string{ + "brief.md": canonBrief, + "README.md": cleanREADME, + "plugin.json": cleanManifest, + "AGENTS.md": cleanAgents, + }) +} + +func TestCheckCleanRepoHasNoDrift(t *testing.T) { + rep, err := Check(cleanRepo(t), abcdLikeConfig()) + if err != nil { + t.Fatalf("Check: %v", err) + } + if len(rep.Surfaces) != 3 { + t.Fatalf("checked %d surfaces, want 3", len(rep.Surfaces)) + } + for _, s := range rep.Surfaces { + if s.Status != StatusOK { + t.Errorf("surface %s: status %s (found %q, missing %v), want ok", s.ID, s.Status, s.Found, s.Missing) + } + } + if rep.Drifted() != 0 { + t.Errorf("Drifted() = %d, want 0", rep.Drifted()) + } +} + +// The acceptance corpus, one variant at a time: each drifted surface must be +// caught, and the report must name the file, the exact drifted text, and the +// canonical line it should carry. +func TestCheckCatchesTheIss143DriftCorpus(t *testing.T) { + cases := []struct { + surfaceID string + file string + content string + wantFound string + }{ + {"readme-strapline", "README.md", drift143README, "An opinionated, intent-driven development framework for product thinkers."}, + {"manifest-description", "plugin.json", drift143Manifest, "A host-agnostic configuration layer for development — a single Go binary"}, + {"conventions-opening", "AGENTS.md", drift143Agents, "configuration layer for development"}, + } + for _, tc := range cases { + t.Run(tc.surfaceID, func(t *testing.T) { + files := map[string]string{ + "brief.md": canonBrief, "README.md": cleanREADME, + "plugin.json": cleanManifest, "AGENTS.md": cleanAgents, + } + files[tc.file] = tc.content + rep, err := Check(writeRepo(t, files), abcdLikeConfig()) + if err != nil { + t.Fatalf("Check: %v", err) + } + var got *SurfaceResult + for i := range rep.Surfaces { + if rep.Surfaces[i].ID == tc.surfaceID { + got = &rep.Surfaces[i] + } + } + if got == nil { + t.Fatalf("surface %s missing from the report", tc.surfaceID) + } + if got.Status != StatusDrifted { + t.Fatalf("status = %s, want drifted", got.Status) + } + if got.File != tc.file { + t.Errorf("File = %q, want %q", got.File, tc.file) + } + if got.Line <= 0 { + t.Errorf("Line = %d, want the 1-based line of the drifted text", got.Line) + } + if !strings.Contains(got.Found, tc.wantFound) { + t.Errorf("Found = %q, want it to contain %q", got.Found, tc.wantFound) + } + // The canonical line the surface should carry is quoted back. + if !strings.Contains(got.Canonical, "A host-agnostic configuration layer for intent-driven development.") { + t.Errorf("Canonical = %q, want the canonical tagline", got.Canonical) + } + if len(got.Missing) == 0 { + t.Errorf("Missing is empty; want the drifted field named") + } + // The other two surfaces stay clean — one drifted surface must not + // smear findings across the registry. + for _, s := range rep.Surfaces { + if s.ID != tc.surfaceID && s.Status != StatusOK { + t.Errorf("collateral finding on %s: %s", s.ID, s.Status) + } + } + }) + } +} + +// All three drifted at once — the shape iss-143 actually recorded. +func TestCheckCatchesAllThreeVariantsTogether(t *testing.T) { + root := writeRepo(t, map[string]string{ + "brief.md": canonBrief, "README.md": drift143README, + "plugin.json": drift143Manifest, "AGENTS.md": drift143Agents, + }) + rep, err := Check(root, abcdLikeConfig()) + if err != nil { + t.Fatalf("Check: %v", err) + } + if rep.Drifted() != 3 { + t.Fatalf("Drifted() = %d, want 3 (all of the iss-143 corpus)", rep.Drifted()) + } +} + +func TestCheckNormalisesEmphasisWhitespaceAndCase(t *testing.T) { + root := writeRepo(t, map[string]string{ + "brief.md": canonBrief, + "README.md": `a *host-agnostic* configuration layer + for INTENT-DRIVEN development
+ +wrapped\n across lines
": "wrapped across lines", + "em—dash and en–dash and non brk": "em-dash and en-dash and non brk", + } + for in, want := range cases { + if got := normalize(in); got != want { + t.Errorf("normalize(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/core/positioning/config.go b/internal/core/positioning/config.go new file mode 100644 index 0000000..51436ef --- /dev/null +++ b/internal/core/positioning/config.go @@ -0,0 +1,277 @@ +package positioning + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/REPPL/abcd-cli/internal/fsutil" +) + +// ConfigRelPath is the committed positioning configuration, relative to the repo +// root. It sits beside the repo's other per-concern lint configuration +// (.abcd/docs-lint.json, .abcd/record-lint.json, .abcd/rules.json) rather than +// inside .abcd/config/, where identity.json already means the git commit-author +// pin — two different things must not share a filename. +const ConfigRelPath = ".abcd/positioning.json" + +// maxConfigBytes caps the guarded config read. The configuration is a short +// committed registry; 256 KiB is far past any real one. +const maxConfigBytes = 256 << 10 + +// Surface kinds — how a surface's self-description is located in its file. +const ( + // KindRegexp locates the text with the first Patterns entry that matches, + // capturing group 1. The capture may span lines (a wrapped paragraph). + KindRegexp = "regexp" + // KindJSONField reads a top-level string field of a JSON manifest, so the + // registry is not tied to one manifest format. + KindJSONField = "json_field" +) + +// Severity levels a repo may set for the whole positioning family. +const ( + SeverityWarn = "warn" + SeverityBlocker = "blocker" +) + +// ErrConfigInvalid wraps every configuration-validation failure, so a caller can +// distinguish a malformed registry from a real drift finding. +var ErrConfigInvalid = errors.New("positioning config is invalid") + +// Config is the committed positioning registry: where the identity block lives, +// how loudly the family reports, and which surfaces render from the block. +type Config struct { + SchemaVersion int `json:"schema_version"` + Block BlockLocation `json:"block"` + // Severity is the whole family's weight: "warn" (the default — highlight, + // never gate) or "blocker" (a repo opting into a hard gate). + Severity string `json:"severity,omitempty"` + // Surfaces is the registry. Empty means the three defaults; a non-empty + // list REPLACES them, so nothing is ever registered silently. + Surfaces []Surface `json:"surfaces,omitempty"` +} + +// Surface is one rendered surface held to the block. +type Surface struct { + ID string `json:"id"` + // Files are candidate locations, in order; the first that exists is the one + // checked. A surface whose candidates are all absent is skipped, never + // reported as drifted. + Files []string `json:"files"` + Kind string `json:"kind"` + // Patterns (KindRegexp) are tried in order; the first that matches wins. + // Each must compile and expose exactly one capture group. + Patterns []string `json:"patterns,omitempty"` + // Field (KindJSONField) is the top-level JSON key holding the description. + Field string `json:"field,omitempty"` + // Requires names the block fields this surface must carry (title, tagline, + // pitch). At least one is required. + Requires []string `json:"requires"` + // Template renders the proposed replacement, over {title}/{tagline}/{pitch}. + // Empty means the required fields joined by a single space. + Template string `json:"template,omitempty"` +} + +// blockedSeverity resolves the family severity, defaulting to warn. +func (c Config) severity() string { + if strings.EqualFold(strings.TrimSpace(c.Severity), SeverityBlocker) { + return SeverityBlocker + } + return SeverityWarn +} + +// surfaces resolves the effective registry: the declared list, or the three +// defaults when none is declared. +func (c Config) surfaces() []Surface { + if len(c.Surfaces) > 0 { + return c.Surfaces + } + return DefaultSurfaces() +} + +// DefaultSurfaces is the canonical three: the README strapline, the +// plugin/package manifest description, and the conventions file's opening +// paragraph. They are data, not branches — a repo overrides them wholesale in +// its own configuration, and the manifest entry lists candidate files so the +// default is not tied to one manifest format. +func DefaultSurfaces() []Surface { + return []Surface{ + { + ID: "readme-strapline", + Files: []string{"README.md"}, + Kind: KindRegexp, + Patterns: []string{ + // A centred HTML strapline, then the first prose paragraph + // under the top-level heading. + `(?s)]*>(.*?)
`, + `(?m)^#[^#\n]*\n+([^\s<#>\[!|*-][^\n]*)$`, + }, + Requires: []string{"tagline"}, + Template: "{tagline}", + }, + { + ID: "manifest-description", + Files: []string{".claude-plugin/plugin.json", "package.json"}, + Kind: KindJSONField, + Field: "description", + Requires: []string{"tagline"}, + Template: "{tagline}", + }, + { + ID: "conventions-opening", + Files: []string{"AGENTS.md"}, + Kind: KindRegexp, + Patterns: []string{ + // The first paragraph after an abcd-managed marker block, then + // the first paragraph after the top-level heading. The + // paragraph terminator accepts end-of-file, so a conventions + // file whose opening paragraph is its last one still locates. + `(?s)\n+(.*?)(?:\n\n|\z)`, + `(?m)^#[^#\n]*\n+([^\s<#>\[!|*-][^\n]*)$`, + }, + Requires: []string{"tagline"}, + Template: "{tagline}", + }, + } +} + +// DefaultConfig is the registry a repo adopts at onboarding when it has no +// committed one: the three default surfaces, warn-tier, pointed at loc. +func DefaultConfig(loc BlockLocation) Config { + return Config{SchemaVersion: 1, Block: loc, Severity: SeverityWarn, Surfaces: DefaultSurfaces()} +} + +// LoadConfig reads and validates .abcd/positioning.json under root. It returns +// (cfg, true, nil) when the registry is present and well formed, (Config{}, +// false, nil) when the repo has not adopted the check, and an ErrConfigInvalid +// error when the file is present but unusable — a malformed registry must never +// read as "no drift". +func LoadConfig(root string) (Config, bool, error) { + path := filepath.Join(root, filepath.FromSlash(ConfigRelPath)) + data, err := fsutil.ReadGuarded(path, maxConfigBytes) + if err != nil { + if os.IsNotExist(err) { + return Config{}, false, nil + } + return Config{}, false, fmt.Errorf("%w: %s: %s", ErrConfigInvalid, ConfigRelPath, err) + } + var cfg Config + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return Config{}, false, fmt.Errorf("%w: %s: %s", ErrConfigInvalid, ConfigRelPath, err) + } + if err := cfg.Validate(); err != nil { + return Config{}, false, err + } + return cfg, true, nil +} + +// Validate checks the registry before any of it is used. Every field arrives as +// committed data, so each one is validated here rather than trusted downstream: +// an unparseable pattern, an escaping path, or an unknown block field is a +// refusal, not a silently skipped surface. +func (c Config) Validate() error { + if c.SchemaVersion != 1 { + return fmt.Errorf("%w: schema_version must be 1, got %d", ErrConfigInvalid, c.SchemaVersion) + } + if !fsutil.ValidRelPath(c.Block.File) { + return fmt.Errorf("%w: block.file %q is not a repo-relative path", ErrConfigInvalid, c.Block.File) + } + if strings.TrimSpace(c.Block.Heading) == "" { + return fmt.Errorf("%w: block.heading is required", ErrConfigInvalid) + } + if s := strings.TrimSpace(c.Severity); s != "" && !strings.EqualFold(s, SeverityWarn) && !strings.EqualFold(s, SeverityBlocker) { + return fmt.Errorf("%w: severity must be %q or %q, got %q", ErrConfigInvalid, SeverityWarn, SeverityBlocker, c.Severity) + } + seen := map[string]bool{} + for i, s := range c.Surfaces { + if err := s.validate(i); err != nil { + return err + } + if seen[s.ID] { + return fmt.Errorf("%w: duplicate surface id %q", ErrConfigInvalid, s.ID) + } + seen[s.ID] = true + } + return nil +} + +func (s Surface) validate(i int) error { + where := fmt.Sprintf("surfaces[%d]", i) + if strings.TrimSpace(s.ID) == "" { + return fmt.Errorf("%w: %s.id is required", ErrConfigInvalid, where) + } + where = fmt.Sprintf("surface %q", s.ID) + if len(s.Files) == 0 { + return fmt.Errorf("%w: %s.files must list at least one candidate", ErrConfigInvalid, where) + } + for _, f := range s.Files { + if !fsutil.ValidRelPath(f) { + return fmt.Errorf("%w: %s.files entry %q is not a repo-relative path", ErrConfigInvalid, where, f) + } + } + switch s.Kind { + case KindRegexp: + if len(s.Patterns) == 0 { + return fmt.Errorf("%w: %s.patterns must list at least one pattern", ErrConfigInvalid, where) + } + for _, p := range s.Patterns { + re, err := regexp.Compile(p) + if err != nil { + return fmt.Errorf("%w: %s pattern %q does not compile: %s", ErrConfigInvalid, where, p, err) + } + if n := re.NumSubexp(); n != 1 { + return fmt.Errorf("%w: %s pattern %q has %d capture groups, want exactly 1", ErrConfigInvalid, where, p, n) + } + } + case KindJSONField: + if strings.TrimSpace(s.Field) == "" { + return fmt.Errorf("%w: %s.field is required for kind %q", ErrConfigInvalid, where, KindJSONField) + } + default: + return fmt.Errorf("%w: %s.kind must be %q or %q, got %q", ErrConfigInvalid, where, KindRegexp, KindJSONField, s.Kind) + } + if len(s.Requires) == 0 { + return fmt.Errorf("%w: %s.requires must name at least one block field", ErrConfigInvalid, where) + } + var probe Block + for _, f := range s.Requires { + if _, ok := probe.Field(f); !ok { + return fmt.Errorf("%w: %s.requires names unknown block field %q (want title, tagline, or pitch)", ErrConfigInvalid, where, f) + } + } + for _, ph := range placeholderRe.FindAllStringSubmatch(s.Template, -1) { + if _, ok := probe.Field(ph[1]); !ok { + return fmt.Errorf("%w: %s.template uses unknown block field {%s}", ErrConfigInvalid, where, ph[1]) + } + } + return nil +} + +// placeholderRe matches a {field} placeholder in a surface template. +var placeholderRe = regexp.MustCompile(`\{([a-zA-Z]+)\}`) + +// render substitutes the block's fields into the surface template, defaulting to +// the required fields joined by a single space when no template is declared. +func (s Surface) render(b Block) string { + tmpl := strings.TrimSpace(s.Template) + if tmpl == "" { + parts := make([]string, 0, len(s.Requires)) + for _, f := range s.Requires { + if v, ok := b.Field(f); ok && v != "" { + parts = append(parts, v) + } + } + return strings.Join(parts, " ") + } + return placeholderRe.ReplaceAllStringFunc(tmpl, func(m string) string { + v, _ := b.Field(placeholderRe.FindStringSubmatch(m)[1]) + return v + }) +} diff --git a/internal/core/positioning/config_test.go b/internal/core/positioning/config_test.go new file mode 100644 index 0000000..ce92d46 --- /dev/null +++ b/internal/core/positioning/config_test.go @@ -0,0 +1,165 @@ +package positioning + +import ( + "errors" + "path/filepath" + "strings" + "testing" +) + +func TestLoadConfigAbsentIsNotAnError(t *testing.T) { + cfg, ok, err := LoadConfig(t.TempDir()) + if err != nil || ok { + t.Fatalf("LoadConfig(bare repo) = %+v, %v, %v; want zero, false, nil", cfg, ok, err) + } +} + +func TestLoadConfigRoundTrip(t *testing.T) { + root := writeRepo(t, map[string]string{ + ConfigRelPath: `{ + "schema_version": 1, + "block": {"file": "brief.md", "heading": "Identity (canonical)"}, + "severity": "blocker", + "surfaces": [ + {"id": "readme-strapline", "files": ["README.md"], "kind": "regexp", + "patterns": ["(?s)(.*?)
"], "requires": ["tagline"], "template": "{tagline}"} + ] +}`, + }) + cfg, ok, err := LoadConfig(root) + if err != nil || !ok { + t.Fatalf("LoadConfig = %v, %v", ok, err) + } + if cfg.Block.File != "brief.md" || cfg.severity() != SeverityBlocker || len(cfg.Surfaces) != 1 { + t.Fatalf("round-trip lost data: %+v", cfg) + } +} + +func TestLoadConfigRejectsMalformedRegistries(t *testing.T) { + cases := map[string]string{ + "not json": `{`, + "unknown field": `{"schema_version":1,"block":{"file":"b.md","heading":"H"},"surfaces":[],"typo":1}`, + "wrong schema": `{"schema_version":2,"block":{"file":"b.md","heading":"H"}}`, + "escaping block path": `{"schema_version":1,"block":{"file":"../outside.md","heading":"H"}}`, + "absolute block path": `{"schema_version":1,"block":{"file":"/etc/passwd","heading":"H"}}`, + "empty heading": `{"schema_version":1,"block":{"file":"b.md","heading":" "}}`, + "bad severity": `{"schema_version":1,"block":{"file":"b.md","heading":"H"},"severity":"loud"}`, + "surface no id": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"files":["README.md"],"kind":"regexp","patterns":["(x)"],"requires":["tagline"]}]}`, + "surface no files": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":[],"kind":"regexp","patterns":["(x)"],"requires":["tagline"]}]}`, + "surface escaping file": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["../x"],"kind":"regexp","patterns":["(x)"],"requires":["tagline"]}]}`, + "unknown kind": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"toml","requires":["tagline"]}]}`, + "pattern does not compile": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"regexp","patterns":["("],"requires":["tagline"]}]}`, + "pattern without a capture group": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"regexp","patterns":["strapline"],"requires":["tagline"]}]}`, + "pattern with two capture groups": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"regexp","patterns":["(a)(b)"],"requires":["tagline"]}]}`, + "json_field without a field": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["p.json"],"kind":"json_field","requires":["tagline"]}]}`, + "no requires": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"regexp","patterns":["(x)"],"requires":[]}]}`, + "unknown required field": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"regexp","patterns":["(x)"],"requires":["slogan"]}]}`, + "unknown template placeholder": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[{"id":"s","files":["README.md"],"kind":"regexp","patterns":["(x)"],"requires":["tagline"],"template":"{slogan}"}]}`, + "duplicate surface id": `{"schema_version":1,"block":{"file":"b.md","heading":"H"}, + "surfaces":[ + {"id":"s","files":["README.md"],"kind":"regexp","patterns":["(x)"],"requires":["tagline"]}, + {"id":"s","files":["AGENTS.md"],"kind":"regexp","patterns":["(x)"],"requires":["tagline"]}]}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + root := writeRepo(t, map[string]string{ConfigRelPath: body}) + _, ok, err := LoadConfig(root) + if err == nil { + t.Fatalf("LoadConfig accepted a malformed registry (ok=%v)", ok) + } + if !errors.Is(err, ErrConfigInvalid) { + t.Fatalf("err = %v, want ErrConfigInvalid", err) + } + // A refusal must never leak the absolute repo path. + if strings.Contains(err.Error(), filepath.ToSlash(root)) { + t.Errorf("error leaks the absolute repo root: %v", err) + } + }) + } +} + +func TestEmptySurfaceListFallsBackToTheThreeDefaults(t *testing.T) { + cfg := Config{SchemaVersion: 1, Block: BlockLocation{File: "b.md", Heading: "H"}} + got := cfg.surfaces() + if len(got) != 3 { + t.Fatalf("default registry has %d surfaces, want 3", len(got)) + } + want := map[string]bool{"readme-strapline": true, "manifest-description": true, "conventions-opening": true} + for _, s := range got { + if !want[s.ID] { + t.Errorf("unexpected default surface %q", s.ID) + } + } + if err := (Config{SchemaVersion: 1, Block: BlockLocation{File: "b.md", Heading: "H"}, Surfaces: got}).Validate(); err != nil { + t.Fatalf("the default surfaces do not validate: %v", err) + } +} + +// The defaults must locate abcd-cli's own surface shapes out of the box, so a +// repo that adopts the check without a bespoke registry is still held to it. +func TestDefaultSurfacesLocateTheCanonicalShapes(t *testing.T) { + root := writeRepo(t, map[string]string{ + "brief.md": canonBrief, "README.md": cleanREADME, + ".claude-plugin/plugin.json": cleanManifest, "AGENTS.md": cleanAgents, + }) + cfg := Config{SchemaVersion: 1, Block: BlockLocation{File: "brief.md", Heading: "Identity (canonical)"}} + rep, err := Check(root, cfg) + if err != nil { + t.Fatalf("Check: %v", err) + } + for _, s := range rep.Surfaces { + if s.Status != StatusOK { + t.Errorf("default surface %s: status %s (file %q, found %q)", s.ID, s.Status, s.File, s.Found) + } + } +} + +// A conventions file whose opening paragraph runs to end-of-file must still +// locate; a locator that needs a trailing blank line silently reports +// "unlocatable" on a perfectly conforming repo. +func TestConventionsOpeningLocatesAtEndOfFile(t *testing.T) { + root := writeRepo(t, map[string]string{ + "brief.md": canonBrief, "README.md": cleanREADME, + ".claude-plugin/plugin.json": cleanManifest, + "AGENTS.md": `# AGENTS.md + + +managed + + +A host-agnostic configuration layer for intent-driven development.`, + }) + rep, err := Check(root, Config{SchemaVersion: 1, Block: BlockLocation{File: "brief.md", Heading: "Identity (canonical)"}}) + if err != nil { + t.Fatalf("Check: %v", err) + } + for _, s := range rep.Surfaces { + if s.ID == "conventions-opening" && s.Status != StatusOK { + t.Fatalf("conventions-opening at EOF: status %s, found %q", s.Status, s.Found) + } + } +} + +func TestSurfaceRenderTemplate(t *testing.T) { + b := Block{Title: "T", Tagline: "TL.", Pitch: "P."} + if got := (Surface{Requires: []string{"tagline"}, Template: "{tagline}"}).render(b); got != "TL." { + t.Errorf("render = %q", got) + } + if got := (Surface{Requires: []string{"tagline", "pitch"}}).render(b); got != "TL. P." { + t.Errorf("default render = %q, want the required fields joined", got) + } + if got := (Surface{Requires: []string{"title"}, Template: "{title} — {tagline}"}).render(b); got != "T — TL." { + t.Errorf("render = %q", got) + } +} From 0f5bef83cb79306311446a04452a1667cdb6e588 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:58:49 +0100 Subject: [PATCH 04/14] feat: re-render drifted surfaces as a proposed diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift is found, never fixed behind the maintainer's back. Propose runs the positioning check and, for every surface that drifted, renders what the surface would say if it derived from the identity block — as a unified diff naming the exact removed line and the canonical one that replaces it. It writes nothing, and there is no flag that makes it: a test snapshots every file's content and mtime around the call and fails on any change. A surface the locator could not find yields no hunk rather than a patch against a line that was never located, and a manifest replacement is re-escaped so the proposed JSON still parses. A further test applies every proposal and re-runs the check, so an adopted proposal is proven to leave the repo clean. Assisted-by: Claude:claude-opus-5 --- internal/core/positioning/check.go | 104 +++++++++---- internal/core/positioning/render.go | 171 +++++++++++++++++++++ internal/core/positioning/render_test.go | 182 +++++++++++++++++++++++ 3 files changed, 428 insertions(+), 29 deletions(-) create mode 100644 internal/core/positioning/render.go create mode 100644 internal/core/positioning/render_test.go diff --git a/internal/core/positioning/check.go b/internal/core/positioning/check.go index 8250a9c..1d0d2c5 100644 --- a/internal/core/positioning/check.go +++ b/internal/core/positioning/check.go @@ -156,55 +156,101 @@ func readFirstPresent(root string, files []string) (rel string, data []byte, ok return "", nil, false, nil } +// span is the byte range of a surface's located self-description within its +// file, plus the decoded text. Keeping the range (not just the text) is what +// lets the re-render propose a replacement in place rather than guessing where +// the text sat. +type span struct { + start, end int // byte offsets into the normalised file text + text string // the decoded value (JSON-unescaped for a manifest field) + // escaped reports that the byte range holds an escaped form, so a + // replacement must be re-escaped before it is spliced back in. + escaped bool +} + // locate extracts the surface's self-description and the 1-based line it starts // on, reporting whether the locator matched at all. func (s Surface) locate(data []byte) (found string, line int, ok bool, err error) { - text := strings.ReplaceAll(string(data), "\r\n", "\n") + text := normalizeNewlines(data) + sp, ok, err := s.locateSpan(text) + if err != nil || !ok { + return "", 0, false, err + } + if sp.start < 0 { + // Located by value but not pinned to a byte range: no citable line. + return sp.text, 0, true, nil + } + return sp.text, 1 + strings.Count(text[:sp.start], "\n"), true, nil +} + +// locateSpan finds the byte range the surface's self-description occupies. +func (s Surface) locateSpan(text string) (span, bool, error) { switch s.Kind { case KindJSONField: - var obj map[string]json.RawMessage - if uerr := json.Unmarshal([]byte(text), &obj); uerr != nil { - // A manifest that is not a JSON object cannot be located; that is a - // locator failure, not a parse crash for the whole audit. - return "", 0, false, nil - } - raw, present := obj[s.Field] - if !present { - return "", 0, false, nil - } - var v string - if uerr := json.Unmarshal(raw, &v); uerr != nil { - return "", 0, false, nil // present but not a string - } - return v, jsonFieldLine(text, s.Field), true, nil + return locateJSONField(text, s.Field) case KindRegexp: for _, p := range s.Patterns { re, cerr := regexp.Compile(p) if cerr != nil { - return "", 0, false, fmt.Errorf("pattern %q does not compile: %w", p, cerr) + return span{}, false, fmt.Errorf("pattern %q does not compile: %w", p, cerr) } loc := re.FindStringSubmatchIndex(text) if loc == nil || loc[2] < 0 { continue } - return text[loc[2]:loc[3]], 1 + strings.Count(text[:loc[2]], "\n"), true, nil + return span{start: loc[2], end: loc[3], text: text[loc[2]:loc[3]]}, true, nil } - return "", 0, false, nil + return span{}, false, nil } - return "", 0, false, fmt.Errorf("%w: unknown surface kind %q", ErrConfigInvalid, s.Kind) + return span{}, false, fmt.Errorf("%w: unknown surface kind %q", ErrConfigInvalid, s.Kind) } -// jsonFieldLine finds the 1-based line a top-level JSON key sits on, so a -// finding can cite file:line. It is a display aid over the already-parsed -// document: a miss yields 0 rather than a wrong line. -func jsonFieldLine(text, field string) int { - key := `"` + field + `"` - for i, l := range strings.Split(text, "\n") { - if strings.Contains(l, key) { - return i + 1 +// jsonStringMemberFmt builds a regexp for a JSON object member whose value is a +// string, capturing the escaped value between the quotes. It recovers the byte +// range the JSON decoder never reports; the authoritative parse decides whether +// that range really is the top-level field asked for. +const jsonStringMemberFmt = `"%s"\s*:\s*"((?:[^"\\]|\\.)*)"` + +// locateJSONField reads a top-level string field of a JSON manifest and finds +// the byte range its value occupies. The value is taken from a real JSON parse +// (so a nested key of the same name can never be mistaken for the top-level +// one); the byte range comes from a scan, and is accepted only when it decodes +// to exactly the parsed value. +func locateJSONField(text, field string) (span, bool, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal([]byte(text), &obj); err != nil { + // A manifest that is not a JSON object cannot be located; that is a + // locator miss, not a fault that should abort the whole audit. + return span{}, false, nil + } + raw, present := obj[field] + if !present { + return span{}, false, nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return span{}, false, nil // present but not a string + } + re, err := regexp.Compile(fmt.Sprintf(jsonStringMemberFmt, regexp.QuoteMeta(field))) + if err != nil { + return span{}, false, nil + } + for _, loc := range re.FindAllStringSubmatchIndex(text, -1) { + var decoded string + if json.Unmarshal([]byte(`"`+text[loc[2]:loc[3]]+`"`), &decoded) != nil || decoded != value { + continue } + return span{start: loc[2], end: loc[3], text: value, escaped: true}, true, nil } - return 0 + // The value parsed but its bytes could not be pinned (an unusual encoding). + // Report the value with no usable range: the check still runs, the + // re-render declines to propose rather than splicing at a guessed offset. + return span{start: -1, end: -1, text: value}, true, nil +} + +// normalizeNewlines renders file bytes as the text every locator works over. +func normalizeNewlines(data []byte) string { + return strings.ReplaceAll(string(data), "\r\n", "\n") } var ( diff --git a/internal/core/positioning/render.go b/internal/core/positioning/render.go new file mode 100644 index 0000000..a782824 --- /dev/null +++ b/internal/core/positioning/render.go @@ -0,0 +1,171 @@ +package positioning + +import ( + "encoding/json" + "fmt" + "strings" +) + +// diffContext is how many unchanged lines flank each hunk. +const diffContext = 3 + +// SurfaceDiff is one surface's proposed correction: the whole proposed file +// content and the unified diff that turns the current file into it. +type SurfaceDiff struct { + SurfaceID string `json:"surfaceId"` + File string `json:"file"` + // Proposed is the surface file as it would read once the proposal is + // adopted. It is returned, never written. + Proposed string `json:"-"` + // Unified is the human-readable patch: the exact drifted line removed, the + // canonical line added, with surrounding context. + Unified string `json:"unified"` +} + +// Proposal is a re-render of every drifted surface from the identity block. It +// is a PROPOSAL: nothing here touches the working tree, and there is no flag +// that makes it. A deliberate identity change is an edit to the block, after +// which the same proposal flow chases the surfaces. +type Proposal struct { + Report Report `json:"report"` + Diffs []SurfaceDiff `json:"diffs"` +} + +// Unified concatenates every surface's patch into one diff a human can read or +// pipe to `git apply`. +func (p Proposal) Unified() string { + var b strings.Builder + for _, d := range p.Diffs { + b.WriteString(d.Unified) + } + return b.String() +} + +// Propose runs the positioning check and, for every surface that drifted, +// renders what the surface would say if it were re-rendered from the block. +// It performs no writes whatsoever. +// +// A surface the locator could not find yields no diff — there is no region to +// replace, and inventing one would propose a patch against a line that was +// never located. It still reports as drifted in the embedded Report. +func Propose(root string, cfg Config) (Proposal, error) { + rep, err := Check(root, cfg) + if err != nil { + return Proposal{}, err + } + byID := map[string]Surface{} + for _, s := range cfg.surfaces() { + byID[s.ID] = s + } + + p := Proposal{Report: rep} + for _, res := range rep.Surfaces { + if res.Status != StatusDrifted { + continue + } + s, ok := byID[res.ID] + if !ok { + continue + } + _, data, present, rerr := readFirstPresent(root, []string{res.File}) + if rerr != nil { + return Proposal{}, rerr + } + if !present { + continue + } + text := normalizeNewlines(data) + sp, located, lerr := s.locateSpan(text) + if lerr != nil { + return Proposal{}, lerr + } + if !located || sp.start < 0 { + continue + } + + replacement := s.render(rep.Block) + if sp.escaped { + // Splicing back into a JSON string literal: the canonical line must + // be re-escaped or the proposed manifest would not parse. + enc, merr := json.Marshal(replacement) + if merr != nil { + return Proposal{}, merr + } + replacement = string(enc[1 : len(enc)-1]) + } + proposed := text[:sp.start] + replacement + text[sp.end:] + p.Diffs = append(p.Diffs, SurfaceDiff{ + SurfaceID: res.ID, + File: res.File, + Proposed: proposed, + Unified: unifiedDiff(res.File, text, proposed, sp.start, sp.end, replacement), + }) + } + return p, nil +} + +// unifiedDiff renders the single-region replacement as a unified diff. The +// change is always one contiguous span, so the patch is exactly one hunk: the +// lines the span touches, replaced, flanked by up to diffContext unchanged +// lines. That is enough to be a real patch without pulling in a diff algorithm. +func unifiedDiff(file, old, proposed string, start, end int, replacement string) string { + oldLines := strings.Split(old, "\n") + firstLine := strings.Count(old[:start], "\n") + lastLine := strings.Count(old[:end], "\n") + + // The replaced lines, rebuilt: everything on the first line before the + // span, the replacement, everything on the last line after it. + lineStart := lineStartOffset(old, firstLine) + lineEnd := lineEndOffset(old, lastLine) + newBlock := old[lineStart:start] + replacement + old[end:lineEnd] + + removed := oldLines[firstLine : lastLine+1] + added := strings.Split(newBlock, "\n") + + ctxStart := max(0, firstLine-diffContext) + ctxEnd := min(len(oldLines)-1, lastLine+diffContext) + before := oldLines[ctxStart:firstLine] + after := oldLines[lastLine+1 : ctxEnd+1] + + var b strings.Builder + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", file, file) + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", + ctxStart+1, len(before)+len(removed)+len(after), + ctxStart+1, len(before)+len(added)+len(after)) + for _, l := range before { + b.WriteString(" " + l + "\n") + } + for _, l := range removed { + b.WriteString("-" + l + "\n") + } + for _, l := range added { + b.WriteString("+" + l + "\n") + } + for _, l := range after { + b.WriteString(" " + l + "\n") + } + return b.String() +} + +// lineStartOffset returns the byte offset of the start of 0-based line n. +func lineStartOffset(s string, n int) int { + off := 0 + for i := 0; i < n; i++ { + idx := strings.IndexByte(s[off:], '\n') + if idx < 0 { + return off + } + off += idx + 1 + } + return off +} + +// lineEndOffset returns the byte offset just past the end of 0-based line n +// (excluding its newline). +func lineEndOffset(s string, n int) int { + off := lineStartOffset(s, n) + if idx := strings.IndexByte(s[off:], '\n'); idx >= 0 { + return off + idx + } + return len(s) +} diff --git a/internal/core/positioning/render_test.go b/internal/core/positioning/render_test.go new file mode 100644 index 0000000..1d7cd8f --- /dev/null +++ b/internal/core/positioning/render_test.go @@ -0,0 +1,182 @@ +package positioning + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// snapshot records every file's content and modification time under root. +func snapshot(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + data, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + rel, _ := filepath.Rel(root, p) + out[rel] = fi.ModTime().String() + "\x00" + string(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestProposeWritesNothing(t *testing.T) { + root := writeRepo(t, map[string]string{ + "brief.md": canonBrief, "README.md": drift143README, + "plugin.json": drift143Manifest, "AGENTS.md": drift143Agents, + }) + before := snapshot(t, root) + + p, err := Propose(root, abcdLikeConfig()) + if err != nil { + t.Fatalf("Propose: %v", err) + } + if len(p.Diffs) == 0 { + t.Fatal("Propose produced no diffs on a drifted repo — nothing to assert about") + } + + after := snapshot(t, root) + if len(before) != len(after) { + t.Fatalf("Propose changed the file set: %d files before, %d after", len(before), len(after)) + } + for rel, was := range before { + if now, ok := after[rel]; !ok || now != was { + t.Errorf("Propose modified %s — it must write nothing", rel) + } + } +} + +func TestProposeEmitsAUnifiedDiffPerDriftedSurface(t *testing.T) { + root := writeRepo(t, map[string]string{ + "brief.md": canonBrief, "README.md": drift143README, + "plugin.json": cleanManifest, "AGENTS.md": cleanAgents, + }) + p, err := Propose(root, abcdLikeConfig()) + if err != nil { + t.Fatalf("Propose: %v", err) + } + if len(p.Diffs) != 1 { + t.Fatalf("got %d diffs, want 1 (only the drifted surface)", len(p.Diffs)) + } + d := p.Diffs[0] + if d.SurfaceID != "readme-strapline" || d.File != "README.md" { + t.Fatalf("diff targets %s/%s", d.SurfaceID, d.File) + } + u := d.Unified + for _, want := range []string{ + "--- a/README.md", + "+++ b/README.md", + "@@ ", + "-An opinionated, intent-driven development framework for product thinkers.
", + "+A host-agnostic configuration layer for intent-driven development.
", + } { + if !strings.Contains(u, want) { + t.Errorf("unified diff missing %q:\n%s", want, u) + } + } + // The surrounding lines are preserved as context, not rewritten. + if !strings.Contains(u, "(.*?)
"], "requires": ["tagline"], "template": "{tagline}"}, + {"id": "manifest-description", "files": [".claude-plugin/plugin.json"], + "kind": "json_field", "field": "description", "requires": ["tagline"], "template": "{tagline}"} + ] +}` + +const cleanStrapline = "A host-agnostic configuration layer for intent-driven development.
\n\nAn opinionated, intent-driven development framework for product thinkers.
\n\n(.*?)
"], "requires": ["tagline"], "template": "{tagline}"} + ] +}` + +const idCleanREADME = "A host-agnostic configuration layer for intent-driven development.
\n\nAn opinionated, intent-driven development framework for product thinkers.
\n\nAn opinionated, intent-driven development framework for product thinkers.
", + "+A host-agnostic configuration layer for intent-driven development.
", + } { + if !strings.Contains(out, want) { + t.Errorf("render output missing %q:\n%s", want, out) + } + } +} + +func TestIdentityRenderOnACleanRepoProposesNothing(t *testing.T) { + t.Chdir(adoptedRepo(t, idCleanREADME)) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"identity", "render"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "--- a/") { + t.Fatalf("clean repo produced a diff:\n%s", stdout.String()) + } +} + +// A repo that has not adopted the check is told how to, not crashed at. +func TestIdentityWithoutARegistryExplainsHowToAdopt(t *testing.T) { + t.Chdir(t.TempDir()) + + var stdout, stderr bytes.Buffer + code := Run([]string{"identity"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("un-adopted repo exited 0:\n%s", stdout.String()) + } + combined := stdout.String() + stderr.String() + if !strings.Contains(combined, "identity init") { + t.Errorf("no pointer to `abcd identity init`:\n%s", combined) + } +} + +// AC1: init lands a parseable block and the pointer that records its location. +func TestIdentityInitScaffoldsTheBlockAndThePointer(t *testing.T) { + repo := idRepo(t, map[string]string{"README.md": idCleanREADME}) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + code := Run([]string{"identity", "init", + "--title", "abcd — Agent-Based Configuration for Development", + "--tagline", "A host-agnostic configuration layer for intent-driven development.", + "--pitch", "A single Go binary that carries the why from idea to shipped reality."}, + &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + + cfg, ok, err := positioning.LoadConfig(repo) + if err != nil || !ok { + t.Fatalf("LoadConfig after init = %v, %v", ok, err) + } + block, err := positioning.ParseBlock(repo, cfg.Block) + if err != nil { + t.Fatalf("the scaffolded block does not parse: %v", err) + } + if block.Tagline != "A host-agnostic configuration layer for intent-driven development." { + t.Errorf("Tagline = %q", block.Tagline) + } + if block.Title == "" || block.Pitch == "" { + t.Errorf("block = %+v", block) + } + // The check the init just enabled must now run, and find no drift: the + // repo's README already carries the tagline it was interviewed for. + var auditOut, auditErr bytes.Buffer + Run([]string{"audit", "--json"}, &auditOut, &auditErr) + var res struct { + Findings []struct { + RuleID string `json:"ruleId"` + Message string `json:"message"` + } `json:"findings"` + } + if err := json.Unmarshal(auditOut.Bytes(), &res); err != nil { + t.Fatalf("audit --json: %v\n%s", err, auditOut.String()) + } + for _, f := range res.Findings { + if f.RuleID == "identity-positioning" { + t.Errorf("audit after init reports positioning drift: %s", f.Message) + } + } +} + +// The pitch is skippable at onboarding; title and tagline are not. +func TestIdentityInitRequiresTitleAndTagline(t *testing.T) { + t.Chdir(idRepo(t, map[string]string{"README.md": idCleanREADME})) + for _, args := range [][]string{ + {"identity", "init", "--tagline", "t"}, + {"identity", "init", "--title", "T"}, + {"identity", "init"}, + } { + var stdout, stderr bytes.Buffer + if code := Run(args, &stdout, &stderr); code == 0 { + t.Errorf("%v was accepted without both required answers", args) + } + } +} + +func TestIdentityInitAcceptsNoPitch(t *testing.T) { + repo := idRepo(t, map[string]string{"README.md": idCleanREADME}) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"identity", "init", "--title", "T", "--tagline", "A host-agnostic configuration layer for intent-driven development."}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + cfg, _, err := positioning.LoadConfig(repo) + if err != nil { + t.Fatal(err) + } + block, err := positioning.ParseBlock(repo, cfg.Block) + if err != nil { + t.Fatalf("block without a pitch does not parse: %v", err) + } + if block.Pitch != "" { + t.Errorf("Pitch = %q, want empty", block.Pitch) + } +} + +// Onboarding detects an existing block and registers it rather than +// re-interviewing and minting a second canon. +func TestIdentityInitDetectsAnExistingBlockInsteadOfReinterviewing(t *testing.T) { + repo := idRepo(t, map[string]string{ + ".abcd/development/brief.md": idBrief, + "README.md": idCleanREADME, + }) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + code := Run([]string{"identity", "init", + "--file", ".abcd/development/brief.md", "--heading", "Identity (canonical)"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "existing") { + t.Errorf("init did not report that it adopted the existing block:\n%s", stdout.String()) + } + // The block file is untouched — no second canon, no rewrite. + data, err := os.ReadFile(filepath.Join(repo, ".abcd/development/brief.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != idBrief { + t.Fatal("init rewrote an existing identity block") + } + cfg, ok, err := positioning.LoadConfig(repo) + if err != nil || !ok || cfg.Block.Heading != "Identity (canonical)" { + t.Fatalf("pointer not registered: %+v %v %v", cfg, ok, err) + } +} + +// Re-running init over a registered block is a no-op, not a clobber. +func TestIdentityInitIsIdempotent(t *testing.T) { + repo := adoptedRepo(t, idCleanREADME) + t.Chdir(repo) + before := snapshotTree(t, repo) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"identity", "init"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + for rel, was := range snapshotTree(t, repo) { + if before[rel] != was { + t.Errorf("re-running init modified %s", rel) + } + } +} + +// A repo whose registry already points somewhere must never be silently +// repointed by an init with different answers. +func TestIdentityInitRefusesToRepointAnAdoptedRegistry(t *testing.T) { + repo := adoptedRepo(t, idCleanREADME) + t.Chdir(repo) + + var stdout, stderr bytes.Buffer + code := Run([]string{"identity", "init", "--title", "Other", "--tagline", "Different."}, &stdout, &stderr) + if code == 0 { + t.Fatal("init overwrote an adopted registry") + } + cfg, _, err := positioning.LoadConfig(repo) + if err != nil { + t.Fatal(err) + } + if cfg.Block.File != ".abcd/development/brief.md" { + t.Fatalf("registry was repointed to %q", cfg.Block.File) + } +} From 057956c5679f7ad12467d598eabd5deee89fc99f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:07:01 +0100 Subject: [PATCH 07/14] fix: keep line structure when sanitising a rendered diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abcd identity render` passed its whole unified diff through the single-line sanitiser, which masks newlines so an injected line break cannot forge report lines. On a diff that is the wrong trade: every newline became a '?' and the patch arrived as one unreadable smear that no tool could apply. termsafe gains SanitizeBlock — the same masking, applied per line with the newlines preserved — and the render path uses it. Bare carriage returns are still masked, so a line cannot be overwritten in place. The surface test now asserts the markers open their own lines. Assisted-by: Claude:claude-opus-5 --- internal/surface/cli/identity.go | 5 +++-- internal/surface/cli/identity_surface_test.go | 16 ++++++++++++++++ internal/termsafe/termsafe.go | 15 +++++++++++++++ internal/termsafe/termsafe_test.go | 15 +++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/surface/cli/identity.go b/internal/surface/cli/identity.go index b935b8c..58f96c1 100644 --- a/internal/surface/cli/identity.go +++ b/internal/surface/cli/identity.go @@ -164,8 +164,9 @@ func renderProposal(w io.Writer, p positioning.Proposal) { fmt.Fprintf(w, "abcd identity render — %d proposed change(s), nothing written. Adopt what you agree with.\n\n", len(p.Diffs)) for _, d := range p.Diffs { // The diff body is the repo's own file content, so it is sanitised - // before it reaches a terminal. - fmt.Fprint(w, termsafe.Sanitize(d.Unified)) + // before it reaches a terminal — per line, because a diff IS lines and + // masking its newlines would smear it into one unreadable string. + fmt.Fprint(w, termsafe.SanitizeBlock(d.Unified)) fmt.Fprintln(w) } } diff --git a/internal/surface/cli/identity_surface_test.go b/internal/surface/cli/identity_surface_test.go index 7bf93b8..c26099a 100644 --- a/internal/surface/cli/identity_surface_test.go +++ b/internal/surface/cli/identity_surface_test.go @@ -177,6 +177,22 @@ func TestIdentityRenderPrintsTheProposedDiff(t *testing.T) { t.Errorf("render output missing %q:\n%s", want, out) } } + // A diff is line-oriented: each marker must open its own line, or the + // output is one unreadable smear and cannot be piped to `git apply`. + for _, want := range []string{ + "\n--- a/README.md\n", + "\n+++ b/README.md\n", + "\n-An opinionated", + "\n+
A host-agnostic",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("render output is not line-oriented, missing %q:\n%q", want, out)
+ }
+ }
+ // Sanitising must not turn the diff's newlines into replacement glyphs.
+ if strings.Contains(out, "README.md?") {
+ t.Errorf("render output flattened its newlines:\n%q", out)
+ }
}
func TestIdentityRenderOnACleanRepoProposesNothing(t *testing.T) {
diff --git a/internal/termsafe/termsafe.go b/internal/termsafe/termsafe.go
index 3b48641..f4c1863 100644
--- a/internal/termsafe/termsafe.go
+++ b/internal/termsafe/termsafe.go
@@ -37,6 +37,21 @@ func Sanitize(s string) string {
}, s)
}
+// SanitizeBlock sanitises multi-line text while keeping its line structure: each
+// line is sanitised on its own and the newlines are preserved. Sanitize masks a
+// newline (an injected line break must not forge extra report lines), which is
+// right for a value interpolated into one line and wrong for output that IS
+// lines — a rendered diff or a quoted file excerpt, where flattening the
+// newlines destroys the artefact the reader needs. Use this only where the line
+// breaks are the render's own, never for a single untrusted value.
+func SanitizeBlock(s string) string {
+ lines := strings.Split(s, "\n")
+ for i, l := range lines {
+ lines[i] = Sanitize(strings.TrimSuffix(l, "\r"))
+ }
+ return strings.Join(lines, "\n")
+}
+
// SanitizeAll sanitises every member of a slice, returning a new slice.
func SanitizeAll(in []string) []string {
if in == nil {
diff --git a/internal/termsafe/termsafe_test.go b/internal/termsafe/termsafe_test.go
index 65f039f..cbc2b20 100644
--- a/internal/termsafe/termsafe_test.go
+++ b/internal/termsafe/termsafe_test.go
@@ -10,6 +10,21 @@ func withRune(prefix string, r rune, suffix string) string {
return prefix + string(r) + suffix
}
+func TestSanitizeBlockKeepsLinesAndStillMasksAttackRunes(t *testing.T) {
+ in := "--- a/README.md\n" + withRune("+ strapline", 0x1b, "[31m") + "\n cONTEXT\n"
+ got := SanitizeBlock(in)
+ if want := "--- a/README.md\n+ strapline?[31m\n cONTEXT\n"; got != want {
+ t.Errorf("SanitizeBlock = %q, want %q", got, want)
+ }
+ // A carriage return would still let a line be overwritten in place.
+ if got := SanitizeBlock("a\r\nb"); got != "a\nb" {
+ t.Errorf("SanitizeBlock(CRLF) = %q, want %q", got, "a\nb")
+ }
+ if got := SanitizeBlock(withRune("a", 0x0d, "b")); got != "a?b" {
+ t.Errorf("SanitizeBlock(bare CR) = %q, want %q", got, "a?b")
+ }
+}
+
// TestSanitizeNeutralisesDisplayAttacks proves each terminal-display attack class
// is masked while ordinary text and tab survive.
func TestSanitizeNeutralisesDisplayAttacks(t *testing.T) {
From dff251090b306ec150892cb016c17e4953e776e4 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Tue, 28 Jul 2026 03:10:09 +0100
Subject: [PATCH 08/14] docs: point abcd-cli at its own identity block and
surface the verb
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
.abcd/positioning.json registers abcd's canon — the brief product
chapter's "Identity (canonical)" section, unchanged — and the three
surfaces that render from it: the README strapline, the plugin manifest
description, and the AGENTS.md opening. All three come out clean.
Adds the /abcd:identity plugin command (bare / render / init, with the
interview wording and the detect-before-asking rule), its brief surface
page and registry row, the identity-positioning row in the audit rule
table, the prepare flow's identity step, the regenerated CLI reference
and surface snapshot, and a CHANGELOG entry.
Assisted-by: Claude:claude-opus-5
---
.../development/brief/04-surfaces/16-audit.md | 1 +
.../brief/04-surfaces/17-identity.md | 85 +++++++++++++++++++
.abcd/development/brief/04-surfaces/README.md | 3 +-
.abcd/positioning.json | 34 ++++++++
.abcd/work/DECISIONS.md | 3 +
CHANGELOG.md | 26 ++++++
commands/abcd/identity.md | 85 +++++++++++++++++++
commands/abcd/prepare-this-repo.md | 50 ++++++++++-
8 files changed, 284 insertions(+), 3 deletions(-)
create mode 100644 .abcd/development/brief/04-surfaces/17-identity.md
create mode 100644 .abcd/positioning.json
create mode 100644 commands/abcd/identity.md
diff --git a/.abcd/development/brief/04-surfaces/16-audit.md b/.abcd/development/brief/04-surfaces/16-audit.md
index f50f808..48e7d17 100644
--- a/.abcd/development/brief/04-surfaces/16-audit.md
+++ b/.abcd/development/brief/04-surfaces/16-audit.md
@@ -37,6 +37,7 @@ error — so `abcd audit` gates a repo's CI as well as backing onboarding.
| `decision-durability` | warn | a committed `.abcd/work/DECISIONS.md`; decisions not living only in the gitignored layer |
| `docs-currency` | warn | reuses the docs-lint engine where `docs/` exists |
| `privacy-hygiene` | error | no absolute local paths in committed files, honouring an `abcd-audit:allow` line waiver |
+| `identity-positioning` | warn | every registered surface still carries the canonical identity block's tagline (and pitch, where required); Where-gated on a committed `.abcd/positioning.json`, and per-repo upgradeable to `error` — see [`17-identity.md`](17-identity.md) |
## How it is built
diff --git a/.abcd/development/brief/04-surfaces/17-identity.md b/.abcd/development/brief/04-surfaces/17-identity.md
new file mode 100644
index 0000000..e345529
--- /dev/null
+++ b/.abcd/development/brief/04-surfaces/17-identity.md
@@ -0,0 +1,85 @@
+# `/abcd:identity` — Repo Positioning
+
+`/abcd:identity` holds every rendered surface of a repository to one canonical
+identity block. The bare and `render` forms are **strictly read-only**; `init` is
+the single write path, and it runs once, at onboarding.
+
+It answers a different question from `/abcd:audit`: `audit` reports whether the
+repo conforms to the working conventions as a whole, and runs the positioning
+check as one rule among them; `identity` is where a maintainer looks at the canon
+itself and at what a fix would read like.
+
+## The identity block
+
+The canonical home is a markdown block in the repo's own record — markdown stays
+the single source of truth, and the committed configuration records only where
+the block lives:
+
+```markdown
+## Identity (canonical)
+
+- **Title:** abcd — Agent-Based Configuration for Development
+- **Tagline:** A host-agnostic configuration layer for intent-driven development.
+- **Pitch:** A single Go binary that carries the why from idea to shipped
+ reality, usable as a plugin in compatible agent harnesses.
+```
+
+Title and tagline are required; the pitch is optional at onboarding and may wrap
+across lines. abcd's own block is the "Identity (canonical)" section of
+[`01-product/README.md`](../01-product/README.md).
+
+## The registry
+
+`.abcd/positioning.json` records the block's location, the family severity, and
+the registered surfaces. It sits beside the repo's other per-concern
+configuration (`docs-lint.json`, `record-lint.json`, `rules.json`) rather than
+under `.abcd/config/`, where `identity.json` already means the git
+commit-author pin.
+
+| field | meaning |
+|---|---|
+| `block` | `{file, heading}` — where the canonical block lives |
+| `severity` | `warn` (default: highlight, never gate) or `blocker` |
+| `surfaces[]` | `{id, files, kind, patterns \| field, requires, template}` |
+
+A surface names candidate `files` (the first that exists is checked, so one entry
+covers several manifest formats), a locator (`kind: "regexp"` with capture-group
+patterns, or `kind: "json_field"` with a top-level key), the block fields it
+`requires`, and the `template` a proposal renders from. An empty `surfaces` list
+means the three defaults; a non-empty one replaces them, so nothing is ever
+registered silently.
+
+## Behaviour
+
+```bash
+abcd identity --json # the block and every surface's verdict; exit 0
+abcd identity render # a unified diff per drifted surface; writes nothing
+abcd identity init … # record the block and the pointer to it
+```
+
+Comparison is by normalised containment: markup, dashes, line wrapping, and case
+are folded away, so a tagline bolded mid-sentence or wrapped across two lines is
+not drift, while a reworded one is. A drifted surface reports the file, the line,
+the exact text it says, and the canonical line it should carry.
+
+**Autonomous rewriting is permanently out of scope.** `render` proposes; the
+maintainer adopts. A deliberate change of positioning is an edit to the block,
+after which the same proposal flow chases the surfaces.
+
+`init` never re-interviews a repo that already has a block — it adopts it —
+and refuses to repoint a registry that is already committed.
+
+## The check
+
+The drift check runs as the `identity-positioning` rule on every `abcd audit`,
+Where-gated on a committed registry so an un-adopted repo is skipped rather than
+failed. Its acceptance corpus is [`iss-143`](../../../work/issues/resolved/iss-143-tagline-three-variant-drift.md),
+the recorded three-variant tagline drift this check exists to catch.
+
+## References
+
+- Plugin command: [`commands/abcd/identity.md`](../../../../commands/abcd/identity.md)
+- Spec: [`spc-19`](../../specs/open/spc-19-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md)
+- Intent: [`itd-102`](../../intents/planned/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md)
+- Onboarding consumer: [`15-prepare-this-repo.md`](15-prepare-this-repo.md)
+- Conformance surface: [`16-audit.md`](16-audit.md)
diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md
index 4d6f353..ed08c91 100644
--- a/.abcd/development/brief/04-surfaces/README.md
+++ b/.abcd/development/brief/04-surfaces/README.md
@@ -19,7 +19,8 @@ The brief's user-facing command surface is the set enumerated below (not all are
| 13 | `/abcd:consult` | shipped | Consult the local sources corpus (`~/.abcd/sources`) and record source→decision provenance — host-delegated command, no Go verb | [`13-consult.md`](13-consult.md) |
| 14 | `/abcd:ingest` | shipped | Register a URL or document into the local sources corpus — host-delegated command, no Go verb | [`14-ingest.md`](14-ingest.md) |
| 15 | `/abcd:prepare-this-repo` | shipped | Bring an owned repo up to abcd's conventions (interim bridge) — host-delegated command, no Go verb | [`15-prepare-this-repo.md`](15-prepare-this-repo.md) |
-| 16 | `/abcd:audit` | shipped | Check whether a repo conforms to the working conventions (three-tier layout, AGENTS.md router, durable decisions, docs currency, privacy hygiene) — read-only, tri-state exit; backs `prepare-this-repo` and gates CI (itd-85) | [`16-audit.md`](16-audit.md) |
+| 16 | `/abcd:audit` | shipped | Check whether a repo conforms to the working conventions (three-tier layout, AGENTS.md router, durable decisions, docs currency, privacy hygiene, identity positioning) — read-only, tri-state exit; backs `prepare-this-repo` and gates CI (itd-85) | [`16-audit.md`](16-audit.md) |
+| 17 | `/abcd:identity` | shipped | Repo positioning: the canonical identity block (title, tagline, pitch) and every rendered surface held to it. Bare renders the block and each surface's verdict; `render` prints the proposed correction as a unified diff and writes nothing (autonomous rewriting is permanently out of scope); `init` records the block and the `.abcd/positioning.json` pointer at onboarding, adopting an existing block rather than re-interviewing. The drift check itself runs as the `identity-positioning` rule on every audit, warn-tier and per-repo upgradeable to blocker (itd-102, spc-19) | [`17-identity.md`](17-identity.md) |
The **Status** column is machine-checked: the `surface_coverage` record-lint rule asserts every `shipped` row has a backing surface (`commands/abcd/ (.*?) `, concatenated with the pitch in the manifest, bolded mid-sentence and line-wrapped in AGENTS.md); containment catches all three iss-143 variants while accepting all three current ones. Consequence: `identity render` fires only on drift, and its template render is a proposal to adopt by hand, not a byte-exact reproduction of conforming prose.
+- 2026-07-28 — itd-102: no scaffold entry point existed in ahoy/prepare for a repo record block, so the verb family gained a minimal `identity init` (write path: block + pointer, atomic, adopts an existing block, refuses to repoint an adopted registry). `abcd launch scaffold` is release-machinery-specific and `ahoy install` writes only abcd's own plumbing; extending either would have widened its remit.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4130a66..6f3ea44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,32 @@ called out in a **Breaking** section.
### Added
+- **`abcd identity` — one canonical identity block, and every surface held to
+ it** (itd-102, spc-19). A project's positioning fragments silently: the README
+ strapline, the package or plugin manifest description, and the conventions
+ file's opening are edited at different moments until three surfaces tell three
+ stories (the recorded iss-143 drift, which is this check's acceptance corpus).
+ A repo now records one canonical markdown block — `- **Title:**`,
+ `- **Tagline:**`, and an optional, wrappable `- **Pitch:**` under a recorded
+ heading — and `.abcd/positioning.json` records only where that block lives, how
+ loudly the family reports, and which surfaces render from it. Markdown stays
+ the single source of truth. The registry is data, not branches: a surface names
+ candidate files (the first present wins, so one entry covers several manifest
+ formats), a locator (a capture-group regexp or a top-level JSON field), the
+ block fields it must carry, and the template a proposal renders from; the three
+ defaults are the README strapline, the manifest description, and the
+ conventions opening, and a declared list replaces them so nothing is registered
+ silently. A new `identity-positioning` rule runs the check on **every**
+ `abcd audit`, naming the file, the exact drifted line, and the canonical line it
+ should carry — warn-tier by default (it highlights, it never gates) and
+ upgradeable per-repo to blocker. `abcd identity` renders the block and each
+ surface's verdict; `abcd identity render` prints the proposed correction as a
+ unified diff and **writes nothing** (autonomous rewriting is permanently out of
+ scope — adopting a proposal is always the maintainer's move); `abcd identity
+ init` records the block and the pointer at onboarding, adopting an existing
+ block rather than re-interviewing over it. `/abcd:prepare-this-repo` gains the
+ interview, detect-first.
+
- **`abcd launch scaffold` — the changelog-driven release-gate scaffolder**
(itd-93, spc-14). Writes the fixed release machinery into a managed repo that
lacks it: `.github/workflows/release.yml` (verify → build → publish, the verify
diff --git a/commands/abcd/identity.md b/commands/abcd/identity.md
new file mode 100644
index 0000000..b11f5d1
--- /dev/null
+++ b/commands/abcd/identity.md
@@ -0,0 +1,85 @@
+---
+name: identity
+description: Show this repo's canonical identity block — title, tagline, pitch — and every rendered surface held to it, and print the proposed correction for any that drifted, by invoking the abcd binary. The bare and render forms perform zero writes; init records the block and the pointer to it.
+argument-hint: "[render|init]"
+---
+
+# `/abcd:identity` repo positioning
+
+A project's positioning fragments silently: the README strapline, the package or
+plugin manifest description, and the conventions file's opening line are each
+edited at different moments, until three surfaces tell three stories. This
+command shows the one canonical identity block the repo records, and which
+surfaces still say it.
+
+## Bare — what this repo says about itself
+
+```bash
+abcd identity --json
+```
+
+emits `{ "block": …, "severity": …, "surfaces": [ … ] }`:
+
+- `block` — the canonical `title`, `tagline`, and optional `pitch`, plus the
+ `file` and `line` they are recorded at.
+- `severity` — the family's weight, `warn` (the default) or `blocker`.
+- `surfaces` — one entry per registered surface: its `id`, the `file` and `line`
+ checked, and a `status` of `ok`, `drifted`, `absent` (no such file in this
+ repo — not a fault), or `unlocatable` (the file is there but the locator
+ matches nothing, so drift would go unseen). A `drifted` entry carries `found`
+ (the exact text the surface says), `missing` (which block fields it no longer
+ carries), and `canonical` (what the block says it should).
+
+Report the block first, then any surface that is not `ok`, naming the file, the
+line, what it says, and what the block says. It exits `0` even when it reports
+drift: this form is a status render, and the gate is `abcd audit`.
+
+## `render` — the proposed correction
+
+```bash
+abcd identity render
+```
+
+prints a unified diff per drifted surface. It **writes nothing**, and no flag
+makes it: adopting a proposal is the maintainer's move. Show the diff, then ask
+whether to apply it. If the maintainer would rather change what the project says
+than what its surfaces say, the fix is an edit to the identity block, after which
+this same command chases the surfaces.
+
+## `init` — record the block
+
+Run this only as part of onboarding a repo, and only after the interview below.
+
+```bash
+abcd identity init --title "…" --tagline "…" [--pitch "…"] [--file An opinionated widget framework for busy people. An opinionated widget framework for busy people. An opinionated widget framework. An opinionated widget framework. An opinionated widget framework for busy people. An opinionated widget framework for busy people. An opinionated widget framework. An opinionated widget framework. An opinionated widget framework. An opinionated widget framework. An opinionated widget framework. An opinionated widget framework for busy people. An opinionated widget framework for busy people. An opinionated widget framework. An opinionated widget framework. An opinionated widget framework. "+long+" An opinionated, intent-driven development framework for product thinkers.