From 8a83a3749b618bdd510a7c25a4b0f05b3582a204 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:46:01 +0100 Subject: [PATCH 1/9] docs: itd-3 rules-loader SOTA-verified design (design gate, awaiting sign-off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the design of record for the itd-3 modular rules loader, reached via the prefer-sota process: an adversary challenged generic just-in-time rule-injection SOTA (CARL, AGENTS.md, Cursor rules, Skills) for repo fit, then a sota-researcher verdict was taken on the fit-surviving hypothesis. Surviving shape: a transport-agnostic core capability (internal/core/rules) with two front doors — the vendor-neutral `abcd rules [domain]` verb and a Claude Code hook (`abcd hook prompt-router`) — not an adapter seam. The verdict confirms this is current SOTA (JIT progressive disclosure) and refines CARL in four ways: event-driven refresh on SessionStart(compact) over fixed-N; per- domain rendered-content dedup with a ledger cleared on reset; keyword+alias recall (zero-dep, no ML); zero model-facing tokens on no-match with out-of-band observability. No build begins until the maintainer signs off the four intent deltas (D1 N- refresh, D2 schema shape, D3 no-match budget, D4 .abcdignore) enumerated in the STOP section. Assisted-by: Claude:claude-opus-4-8 --- .../plans/2026-07-11-itd-3-rules-loader.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 .abcd/development/plans/2026-07-11-itd-3-rules-loader.md diff --git a/.abcd/development/plans/2026-07-11-itd-3-rules-loader.md b/.abcd/development/plans/2026-07-11-itd-3-rules-loader.md new file mode 100644 index 00000000..e02c58d3 --- /dev/null +++ b/.abcd/development/plans/2026-07-11-itd-3-rules-loader.md @@ -0,0 +1,274 @@ +# itd-3 modular rules loader — SOTA-verified design + +**Status:** design recorded 2026-07-11, **awaiting maintainer sign-off** before +any implementation. Reached via the [prefer-sota](../principles/prefer-sota.md) +process: an adversary challenged generic just-in-time rule-injection SOTA (CARL, +AGENTS.md, Cursor rules, Skills) for THIS repo's fit, then a `sota-researcher` +verdict was taken on the fit-surviving hypothesis. This doc is the design of +record for [itd-3](../intents/planned/itd-3-modular-rules-loader.md); it does not +authorise a build. Adopting a planned intent is a maintainer decision — the four +deltas from the intent (§7) are the sign-off surface. + +## The decision + +Ship a **modular rules loader**: default rule domains bundled in the Go binary + +a per-repo `.abcd/rules.json` override, recall-matched against each prompt and +injected just-in-time so the plugin's discipline never force-loads a monolithic +CLAUDE.md. Rule-loading is a **transport-agnostic core capability** with two +front doors — the vendor-neutral `abcd rules [domain]` CLI verb, and a Claude +Code hook (`abcd hook prompt-router`, wired via `hooks/hooks.json` on the +`UserPromptSubmit` event) that shells to the same core and injects its rendered +result. The last piece is a new `OPINIONS`/`CONVENTIONS` default domain whose +rules **point at** `.abcd/development/principles/` rather than copying them. + +## Why this form (adversary → SOTA verdict) + +### What the adversary rejected, each for a named repo preference + +- **CARL's "the hook owns the logic" architecture** — collides with the + transport-agnostic core (AGENTS.md boundary). The hook must be a dumb shim over + `internal/core`; the capability lives in the core and renders through the + vendor-neutral `abcd rules` verb. +- **CARL's global `~/.carl/carl.json` source** — collides with per-repo-only + ([the intent's "What's Out of Scope"](../intents/planned/itd-3-modular-rules-loader.md); + `~/.abcd/` holds only machine-wide state). No `~/.abcd/rules.json`. Ever. +- **CARL's MCP server + `add_rule`/`toggle_domain` verbs, and the Python hook** — + each is a new runtime/dependency (ask-first). Rule *mutation* is a file-edit of + `.abcd/rules.json` via standard tools; the hook is a Go subcommand; recall is + Go-stdlib keyword matching. Net new dependencies: **zero**. +- **Bundling principle *text* in the binary for the OPINIONS domain** — collides + with [one-canonical-primitive](../principles/one-canonical-primitive.md). The + domain carries short directives + a pointer to the canonical principle file, + never the principle body. +- **CARL's MCP verb-set as the CLI shape** — collides with the + bare-command-as-render discipline (`02-constraints/04-naming.md`). Bare + `abcd rules` renders; `abcd rules ` is the sanctioned positional scope; + no `show` sub-verb. +- **Best-effort silent injection** — collides with fail-closed input parsing and + loud-staging. The hook reuses the in-repo `verifyHookManifest` fail-closed + template (`internal/core/ahoy/store.go`): size caps, symlink refusal, schema + validation, inject-nothing-and-surface-loudly on any parse failure. + +### The surviving-fit hypothesis (the shape that survived every collision) + +**A core capability with the Claude Code hook as one thin front door — not an +adapter seam.** `internal/core/rules` owns `merge(bundled defaults, repo +rules.json) → schema-validate → recall-match(prompt) → dedup → render`. That core +is transport-free and exercised directly by `abcd rules`, testable with zero +harness. The Claude Code coupling is quarantined to two shim entrypoints +(`prompt-router`, `prompt-router-reset`) plus `hooks/hooks.json`. This is the +**same core-plus-front-doors reconciliation the repo already uses for CLI-and- +later-MCP** — rule-loading is not a swappable backend behind an interface (adr-22 +seams), it is one core verb surfaced through whichever front door is present. The +host-agnosticism fork resolves as: the *capability* stays host-agnostic in the +core and renders through `abcd rules`; only the *injection transport* is +Claude-Code-specific, exactly as only the *CLI transport* is Cobra-specific. + +### The SOTA verdict (fit-aware) + +The `sota-researcher` verdict on the fit-surviving hypothesis: it **is** current +SOTA — *just-in-time context loading via progressive disclosure* (Anthropic's +Agent Skills three-stage model and "effective context engineering" guidance are +the primary-source statements of exactly this shape: a small always-on index, +the bulk loaded only on relevance). The capability-in-core / hook-as-front-door +split matches the field's convergence (AGENTS.md as the cross-vendor instruction +standard under the Linux Foundation's Agentic AI Foundation alongside MCP). + +**One load-bearing refinement the verdict adds over CARL:** Skills/MCP relevance +selection is *model-driven* (the agent elects to load). For **behavioural rules** +that is the wrong instrument — the evidence is that models silently ignore rules +they were trusted to load. **Deterministic keyword pre-filtering at the prompt +boundary is the better-fit variant for rules specifically, and is not superseded +by Skills.** Keyword-recall is the right primitive here. (Additive future note: +domains could later be *exported* as Skills to ride the cross-vendor standard — +not a redesign.) + +## The design + +Seven parts. Parts 1–6 are the loader; part 7 is the OPINIONS domain. + +### 1. `internal/core/rules` — the transport-agnostic core + +One package per capability (adr-23), returning structured results, no stdout, no +harness knowledge. Public surface (indicative): + +- `Defaults() RuleSet` — the binary-embedded default domains (Go `embed`). +- `Load(repoRoot) (RuleSet, []Finding, error)` — merge defaults with + `/.abcd/rules.json`, schema-validate, return findings on drift. +- `Match(rs RuleSet, prompt string) []Domain` — deterministic recall. +- `Render(domains []Domain) string` — the injected text (and the `abcd rules` + output). One renderer, both front doors. +- Dedup lives in the hook front door (session state), not the pure core. + +### 2. `rules.json` schema (embedded, JSON Schema 2020-12) + +**Keep the shipped `stepRules` shape** (`apply.go`), reject the legacy-harvest +`extends`/`overrides` sketch (it re-imports CARL's mergeable-global complexity +the per-repo decision rejected). Custom domains are additional keys under +`domains`, not a separate `custom_domains` block: + +```json +{ + "schema_version": 1, + "disabled": false, + "domains": { + "COMMITTING": { + "state": "active", + "recall": ["commit", "pr", "git add", "push"], + "aliases": ["pull request", "committing"], + "rules": ["..."] + } + } +} +``` + +- **`disabled`** (top-level) — repo-wide kill switch. A `*` star-command + must **not** bypass it (a kill switch a star-command can defeat is not a kill + switch). +- **`state`** per domain — `active` | `dormant`. `dormant` silences one domain; + `*` overrides `dormant` but not top-level `disabled`. +- **`recall`** + **`aliases`** — the author-curated trigger set (see part 4). +- Domain keys constrained to `[A-Z][A-Z0-9_]*`; rejected before any use. + +### 3. Recall matching (part 4 detail) — deterministic, zero-dep + +Tokenize the prompt (split on non-alphanumeric → `strings.ToLower` → set), then +boolean membership against each active domain's `recall` + `aliases`. Case-fold +always. **Author-declared aliases are the highest-value dep-free lever** — recall +quality moves to the person who knows the domain's vocabulary. No TF/BM25/ +embeddings (this is a curated boolean trigger, not corpus retrieval — ranking is +meaningless and adds false-positive bloat). Suffix stemming is **deferred behind +an eval**, not shipped on spec (`test`→`testing`→`attestation` false positives). + +### 4. Injection + refresh discipline (the hook front door) + +- On **`UserPromptSubmit`**: recall-match, dedup, inject matched domains. +- On **`SessionStart`** (`source` = `startup|resume|compact`): re-inject header + + currently-live domains. **The `compact` source is the surgical post-compaction + refresh** — strictly better than a fixed prompt counter, because compaction + (not prompt count) is the real threat to rule persistence. +- **`PreCompact`**: record what was live; do **not** re-inject there (it fires + before the summary erases the text). Re-injection happens on the following + `SessionStart(compact)`. +- **Fixed-N re-inject is demoted to a large backstop (N≈15–20)** for + always-relevant domains that never recall-match — not the primary mechanism. + (This is delta D1 in §7.) + +### 5. Dedup signature + +**Per-domain FNV-1a (or sha256) content hash of the *rendered* block** — Go +stdlib, zero deps. Hash rendered text, not rule-id sets (id-only misses a +mid-session `rules.json` edit). Per-domain (not one whole-payload hash) so one +changed domain re-injects without re-sending the rest. + +**Critical B1×B2 coupling:** the "already-injected" ledger **must be cleared on +`SessionStart(compact|clear)`** — after compaction the injected text is gone from +context, so a surviving signature would wrongly suppress the re-injection you +need most. Reset ledger → re-inject → repopulate. This is the single most +important correctness point in the design. + +### 6. `abcd rules [domain]` CLI verb + `abcd hook` entrypoints + +- Bare `abcd rules` → render the active rule set (read-only, never mutates). +- `abcd rules ` → scope to one domain (positional, no `show` sub-verb). +- `abcd hook prompt-router` / `abcd hook prompt-router-reset` — the Claude Code + hook entrypoints (read hook JSON on stdin, emit `additionalContext`, + fail-closed). **Operator-internal transport, not a user command surface.** + +### 7. The OPINIONS / CONVENTIONS default domain + +The payoff of the `opinions.md` decision (rejected as a file; the win is a +default domain here). Recall keywords on convention/opinion/principle/"how do we" +triggers; each rule is a **short directive + a pointer** to the canonical file +under `.abcd/development/principles/` — never a copy (one-canonical-primitive). +Pointer resolution uses a **fixed allowlist of principle paths**, never an +id-derived filesystem path (path-traversal defence). Wired so managed repos +inherit it via `ahoy` (it ships in the binary-embedded defaults). + +## Forks resolved (the intent's Open Questions) + +| Fork | Resolution | Basis | +|---|---|---| +| N-refresh value | Event-driven primary (`SessionStart(compact)`); N≈15–20 backstop only | SOTA B1 — **delta D1** | +| `.abcdignore` | **Reject for v1** — a second override surface competing with `rules.json`; `dormant`/no-match already cover it | fit | +| `rules.json` shape | Keep shipped `{schema_version,disabled,domains{}}`; reject `extends/overrides` | fit + shipped code | +| Star-command parse | `(?:^|\s)\*([A-Z][A-Z0-9_]*)(?=$|\s)` — uppercase+boundary avoids `* bullet` / `*.py` collisions | fit | +| Dedup signature | Per-domain rendered-content hash; ledger cleared on reset | SOTA B2 | +| `state` vs `disabled` | Both kept — different scopes; `*DOMAIN` overrides `dormant`, not `disabled` | fit | +| No-match budget | **Zero model-facing tokens**; observability out-of-band | SOTA B4 — **delta D3** | + +## Trust boundary (security posture, reused from `verifyHookManifest`) + +The hook parses untrusted prompt text + injects context. Fail-closed: + +- **Prompt-injection into the injected message** — the hook emits abcd's *own* + rule text only; the prompt is matched, **never reflected** into the system + message. +- **Path traversal** via a custom domain/rule id — ids constrained to + `[A-Z][A-Z0-9_]*`; OPINIONS pointers resolve against a fixed allowlist. +- **Resource blowup** on a huge prompt — cap the scanned prompt length (mirror + the 256KB `hooks.json` cap + irregular-file refusal in `store.go`). +- **Malicious `rules.json`** — schema-validate before use, size-cap, refuse a + symlinked leaf, fail closed (inject nothing + surface loudly) on any failure. + +The `security-reviewer` agent runs before the hook + injection logic are +presented (Phase 2); a BLOCK verdict stops the change. + +## Gate + surface interactions + +- **`surface_coverage` record-lint rule** — `abcd rules` needs a registry entry + in `04-surfaces/README.md` (+ a `commands/abcd/` markdown if surfaced under + `/abcd:`). `abcd hook *` is a transport entrypoint, not a user surface — + confirm the rule treats it as operator-internal (like other non-surface verbs). +- **Hook-manifest verify (`store.go`)** — `requiredHookCommand` currently expects + the substrings `prompt_router_hook` / `prompt_router_reset`. With the Go + subcommand spelled `abcd hook prompt-router`, Phase 4 must reconcile: update + the verify substrings to `hook prompt-router` / `hook prompt-router-reset` (and + `detect_test.go`). This is a **behaviour-preserving reconciliation of a stale + manifest expectation**, kept in its own commit. +- **Marker block drift** — `defaults/claude-md-marker-block.md` describes the + loader in present tense as if it works. It stays factually wrong until the + loader exists; Phase 4 rewrites it to be present-tense-accurate. Until then it + is known drift, not a new claim. + +## Implementation phasing (only after sign-off — TDD, mirrors task 5) + +Each phase: a test watched fail then pass; `make preflight` + `make record-lint` +green before each commit; `Assisted-by: Claude:claude-opus-4-8` trailer; work on +an `auto/*` branch; no push/merge/PR. + +1. **Core + schema + merge** — `internal/core/rules`: embedded defaults, + `rules.json` schema, `Load`/`Match`/`Render`, dedup helper. Pure, no harness. +2. **Hook front door** — `abcd hook prompt-router` / `-reset`: stdin hook-JSON + parse, recall→dedup→inject, `SessionStart(compact)` refresh, star-command + bypass, fail-closed posture. `security-reviewer` before presenting. +3. **`abcd rules [domain]` verb** — bare-render + positional scope, wired into + `internal/surface/cli`, `--json` parity. +4. **ahoy wiring** — `stepRules` writes the real default `rules.json` (not the + empty `domains:{}` skeleton); `hooks/hooks.json` manifest; reconcile + `requiredHookCommand`; rewrite the marker block present-tense-accurate. +5. **OPINIONS domain** — recall keywords + pointer-rules into + `.abcd/development/principles/`; inherited by managed repos via ahoy. + +## STOP — maintainer sign-off required + +Per the design gate, implementation does not start until the maintainer signs off +**these four deltas from the intent** (each changes an intent commitment, so it is +a fidelity decision, not a design detail): + +- **D1 — N-refresh.** Intent + `config.json` say re-inject every N=5. Design + demotes fixed-N to a ~15–20 backstop and makes `SessionStart(compact)` the + primary refresh. *Config key `rules.force_refresh_every_n` stays but changes + meaning (backstop, not primary) and default.* +- **D2 — schema shape.** Design locks the shipped + `{schema_version,disabled,domains{}}` shape and **rejects** the legacy-harvest + `extends`/`overrides`/`custom_domains` sketch. Confirm the harvest sketch is + superseded. +- **D3 — no-match budget.** Intent's acceptance criterion caps no-match overhead + at "<200 tokens (header only)". Design reframes it to **0 model-facing tokens on + no-match + a diagnostic log line every run**. This edits an acceptance + criterion — maintainer's call. +- **D4 — `.abcdignore` rejected for v1.** Intent lists it as an open question; + design declines it. Confirm. + +On sign-off, this doc becomes the build contract and Phase 1 begins. From 2a5746ea8260e3d88335c13c61f1bbb69bf01a26 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:30:19 +0100 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20itd-3=20phase=201=20=E2=80=94=20int?= =?UTF-8?q?ernal/core/rules=20loader=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport-agnostic core of the modular rules loader: binary-embedded default domains (COMMITTING, DOCUMENTATION, ROADMAP, ISSUES, INTENTS, LIFEBOAT, PII), per-repo .abcd/rules.json merge (per-field domain override, sticky kill switch), hand-rolled zero-dep validation, word-bounded recall matching with multi-word aliases, RE2-safe * star-command parsing, one shared renderer, and per-domain FNV-1a dedup signatures. Fail-closed: a malformed or invalid rules.json is an error the caller surfaces, never a silent fall-back. No model-facing tokens on a no-match (D3): Render(nil) is zero bytes. Not yet wired to a front door — the `abcd rules` verb (phase 3) and the prompt-router hook (phase 2) land next; this core does nothing on the live path until they do. Tests watched fail then pass; make preflight green (build/vet/test/race). Assisted-by: Claude:claude-opus-4-8 --- .abcd/work/DECISIONS.md | 11 + internal/core/rules/defaults/rules.json | 75 ++++++ internal/core/rules/rules.go | 313 ++++++++++++++++++++++++ internal/core/rules/rules_test.go | 269 ++++++++++++++++++++ 4 files changed, 668 insertions(+) create mode 100644 internal/core/rules/defaults/rules.json create mode 100644 internal/core/rules/rules.go create mode 100644 internal/core/rules/rules_test.go diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 2817c7b4..3db34fa1 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -173,3 +173,14 @@ parallel-agent merge contention bites. the `UserPromptSubmit` router is a Go subcommand invoked by `hooks/hooks.json` — the intent's `hooks/prompt_router_hook.py` is a stale pre-Go-rebuild detail and is superseded. No Python is added for the loader. +- 2026-07-11 — itd-3 rules-loader **design signed off** (plan + `2026-07-11-itd-3-rules-loader.md`, prefer-sota verdict). Surviving shape: a + transport-agnostic `internal/core/rules` capability with two front doors + (`abcd rules [domain]` verb + `abcd hook prompt-router`), **not** an adapter + seam. Four intent deltas approved: **D1** event-driven refresh on + `SessionStart(compact)` (fixed-N demoted to a ~15–20 backstop, not primary); + **D2** keep the shipped `{schema_version,disabled,domains{}}` shape (legacy + `extends`/`overrides` sketch superseded); **D3** zero model-facing tokens on + no-match + out-of-band diagnostic log (supersedes the "<200-token header" + acceptance criterion); **D4** `.abcdignore` rejected for v1. Build proceeds + phased/TDD from Phase 1 (`internal/core/rules`). diff --git a/internal/core/rules/defaults/rules.json b/internal/core/rules/defaults/rules.json new file mode 100644 index 00000000..76a9c7e6 --- /dev/null +++ b/internal/core/rules/defaults/rules.json @@ -0,0 +1,75 @@ +{ + "schema_version": 1, + "disabled": false, + "domains": { + "COMMITTING": { + "state": "active", + "recall": ["commit", "push", "branch", "merge", "rebase"], + "aliases": ["pull request", "pr", "git add", "conventional commit"], + "rules": [ + "Conventional prefix (feat/fix/chore/refactor/docs/test, no scopes); small atomic commits, one purpose each.", + "AI-assisted commits carry an Assisted-by trailer (kernel format); never Co-Authored-By for AI.", + "Substantive work goes on a branch + PR; never force-push, never --no-verify.", + "Never commit or push unless asked." + ] + }, + "DOCUMENTATION": { + "state": "active", + "recall": ["docs", "documentation", "readme", "tutorial", "reference", "changelog", "adr"], + "aliases": ["how-to", "how to", "diataxis"], + "rules": [ + "Markdown is the single source of truth; HTML is a generated, disposable artefact.", + "Docs are present tense only — what IS, never what was superseded or planned.", + "One Diataxis type per page (tutorial / how-to / reference / explanation).", + "User-facing prose in British English; code-side identifiers, comments, strings in US English." + ] + }, + "ROADMAP": { + "state": "active", + "recall": ["roadmap", "milestone", "phase"], + "aliases": ["build plan"], + "rules": [ + "The roadmap is intent-driven; each phase ends in a milestone.", + "No time estimates — the press-release intent format carries priority, not dates." + ] + }, + "ISSUES": { + "state": "active", + "recall": ["issue", "bug", "capture", "ledger"], + "aliases": ["found a bug", "track this"], + "rules": [ + "Capture issues to the ledger via `abcd capture`; folder membership is the status signal.", + "Never fix ahead of an armed detector — record the finding first." + ] + }, + "INTENTS": { + "state": "active", + "recall": ["intent", "acceptance", "spec"], + "aliases": ["press release", "press-release", "acceptance criteria"], + "rules": [ + "Intents are press-release-first; acceptance criteria are BDD (Given/When/Then).", + "Personas are always Alice, Bob, Carol — never other names.", + "Lifecycle is drafts/ -> planned/ -> shipped/; a planned intent's adoption is a maintainer decision." + ] + }, + "LIFEBOAT": { + "state": "active", + "recall": ["lifeboat", "disembark", "embark", "handoff", "handover"], + "aliases": ["pack up", "recover the session"], + "rules": [ + "The lifeboat is the floor of recoverable theory, not the theory itself (Naur) — hunt the originating session.", + "Runtime artefacts stay in .abcd/.work.local/; never commit scratch output into tracked directories." + ] + }, + "PII": { + "state": "active", + "recall": ["secret", "token", "credential", "pii", "redact", "hostname", "email"], + "aliases": ["api key", "access token", "personal data", "absolute path"], + "rules": [ + "Never commit, print, or paste secrets, tokens, or .env contents; reference them by name.", + "Never put absolute local paths in anything that leaves the machine — use repo-relative paths.", + "Never name a private repo in commits, PRs, issues, or docs; describe it generically." + ] + } + } +} diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go new file mode 100644 index 00000000..7445a989 --- /dev/null +++ b/internal/core/rules/rules.go @@ -0,0 +1,313 @@ +// Package rules is abcd's transport-agnostic modular rules loader (itd-3). It +// owns the whole capability behind two front doors: the `abcd rules [domain]` +// CLI verb and the Claude Code prompt-router hook. Nothing here writes to stdout +// or knows about a harness event — the front doors under internal/surface and +// the hook entrypoint marshal these results for their transport. +// +// The model is a small set of binary-bundled default domains (embedded below) +// merged with an optional per-repo /.abcd/rules.json override. Each +// domain carries recall keywords + aliases and a list of rules; a prompt is +// recall-matched against the active domains and only the matching rules are +// rendered for injection. A leading * star-command activates a domain +// unconditionally (overriding a dormant state, but never the top-level kill +// switch). +// +// Validation is hand-rolled Go (zero new dependencies): a rules.json that fails +// to parse or validate is a fail-closed error the caller surfaces loudly — it +// never silently degrades to zero injection. +package rules + +import ( + _ "embed" + "encoding/json" + "fmt" + "hash/fnv" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// RepoRelPath is the per-repo override file, relative to the repo worktree. +const RepoRelPath = ".abcd/rules.json" + +// Domain state values. An empty string is treated as active. +const ( + StateActive = "active" + StateDormant = "dormant" +) + +// Domain is one keyed rule domain. The map key in RuleSet.Domains is its name; +// the name is therefore not a serialized field on the value. +type Domain struct { + State string `json:"state,omitempty"` + Recall []string `json:"recall,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Rules []string `json:"rules,omitempty"` +} + +// RuleSet is the merged, validated rule model: the bundled defaults overlaid +// with the per-repo override. +type RuleSet struct { + SchemaVersion int `json:"schema_version"` + Disabled bool `json:"disabled"` + Domains map[string]Domain `json:"domains"` +} + +// ResolvedDomain pairs a domain with its name for ordered rendering and dedup. +type ResolvedDomain struct { + Name string + Domain +} + +// domainNameRe constrains domain keys so a custom domain id can never be used +// to build a filesystem path (path-traversal defence) — uppercase, starting +// with a letter. +var domainNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +// starCommandRe finds a candidate * token. Go's RE2 has no lookahead, +// so the pinned boundary semantics `(?:^|\s)\*([A-Z][A-Z0-9_]*)(?=$|\s)` are +// enforced by checking the surrounding bytes in parseStarCommands. +var starCommandRe = regexp.MustCompile(`\*([A-Z][A-Z0-9_]*)`) + +// nonAlnumRe collapses every run of non-alphanumeric characters to one space so +// recall matching is word-bounded (no substring false positives). +var nonAlnumRe = regexp.MustCompile(`[^a-z0-9]+`) + +//go:embed defaults/rules.json +var defaultsJSON []byte + +// defaultRuleSet is parsed once at init; a malformed embedded asset is a build +// error surfaced as a panic (it can never happen at runtime). +var defaultRuleSet = mustParseDefaults() + +func mustParseDefaults() RuleSet { + var rs RuleSet + if err := json.Unmarshal(defaultsJSON, &rs); err != nil { + panic("rules: bundled defaults are malformed: " + err.Error()) + } + if err := Validate(rs); err != nil { + panic("rules: bundled defaults fail validation: " + err.Error()) + } + return rs +} + +// Defaults returns a deep copy of the binary-bundled default rule set, safe for +// the caller to mutate. +func Defaults() RuleSet { return cloneRuleSet(defaultRuleSet) } + +// Load returns the defaults merged with /.abcd/rules.json when that +// file exists. An absent file yields the defaults unchanged; a present file +// that cannot be parsed or fails validation is a fail-closed error. +func Load(repoRoot string) (RuleSet, error) { + path := filepath.Join(repoRoot, ".abcd", "rules.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return Defaults(), nil + } + if err != nil { + return RuleSet{}, fmt.Errorf("rules: reading %s: %w", RepoRelPath, err) + } + var over RuleSet + if err := json.Unmarshal(data, &over); err != nil { + return RuleSet{}, fmt.Errorf("rules: %s is not valid JSON: %w", RepoRelPath, err) + } + merged := Merge(Defaults(), over) + if err := Validate(merged); err != nil { + return RuleSet{}, fmt.Errorf("rules: %s: %w", RepoRelPath, err) + } + return merged, nil +} + +// Merge overlays over onto base. Domain fields are per-field: a field set on the +// override wins; an absent field inherits the base (so {"state":"dormant"} on a +// default domain silences it while keeping its recall and rules). New domain +// keys are added. The kill switch is sticky (either side can enable it). +func Merge(base, over RuleSet) RuleSet { + out := cloneRuleSet(base) + if over.SchemaVersion != 0 { + out.SchemaVersion = over.SchemaVersion + } + out.Disabled = base.Disabled || over.Disabled + for name, od := range over.Domains { + out.Domains[name] = mergeDomain(out.Domains[name], od) + } + return out +} + +func mergeDomain(base, over Domain) Domain { + r := base + if over.State != "" { + r.State = over.State + } + if over.Recall != nil { + r.Recall = append([]string(nil), over.Recall...) + } + if over.Aliases != nil { + r.Aliases = append([]string(nil), over.Aliases...) + } + if over.Rules != nil { + r.Rules = append([]string(nil), over.Rules...) + } + return r +} + +// Validate checks structural invariants: schema_version == 1, every domain name +// matches [A-Z][A-Z0-9_]*, and every state is active/dormant (or empty). +func Validate(rs RuleSet) error { + if rs.SchemaVersion != 1 { + return fmt.Errorf("schema_version must be 1, got %d", rs.SchemaVersion) + } + for name, d := range rs.Domains { + if !domainNameRe.MatchString(name) { + return fmt.Errorf("domain name %q must match [A-Z][A-Z0-9_]*", name) + } + switch d.State { + case "", StateActive, StateDormant: + default: + return fmt.Errorf("domain %q: unknown state %q", name, d.State) + } + } + return nil +} + +// Match returns the domains to inject for prompt, in deterministic (name-sorted) +// order. The top-level kill switch suppresses everything. Otherwise a domain is +// injected if a star-command names it (overriding dormant) or — when active — +// its recall keywords or aliases hit the prompt. +func (rs RuleSet) Match(prompt string) []ResolvedDomain { + if rs.Disabled { + return nil + } + stars := parseStarCommands(prompt) + norm := normalize(prompt) + + names := make([]string, 0, len(rs.Domains)) + for name := range rs.Domains { + names = append(names, name) + } + sort.Strings(names) + + var out []ResolvedDomain + for _, name := range names { + d := rs.Domains[name] + if stars[name] { + out = append(out, ResolvedDomain{Name: name, Domain: d}) + continue + } + if d.State == StateDormant { + continue + } + if recallHit(norm, d) { + out = append(out, ResolvedDomain{Name: name, Domain: d}) + } + } + return out +} + +// parseStarCommands extracts the set of * names, enforcing that the star +// is at the start or preceded by whitespace and the name is followed by the end +// or whitespace (the RE2-safe form of the pinned lookahead boundary). +func parseStarCommands(prompt string) map[string]bool { + out := map[string]bool{} + for _, loc := range starCommandRe.FindAllStringSubmatchIndex(prompt, -1) { + starStart, nameEnd := loc[0], loc[3] + if starStart > 0 && !isSpace(prompt[starStart-1]) { + continue + } + if nameEnd < len(prompt) && !isSpace(prompt[nameEnd]) { + continue + } + out[prompt[loc[2]:loc[3]]] = true + } + return out +} + +func isSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f' || b == '\v' +} + +// normalize lowercases prompt, collapses non-alphanumeric runs to single spaces, +// and pads with a leading and trailing space so a term wrapped in spaces matches +// on word boundaries. +func normalize(s string) string { + lowered := strings.ToLower(s) + collapsed := nonAlnumRe.ReplaceAllString(lowered, " ") + return " " + strings.TrimSpace(collapsed) + " " +} + +// recallHit reports whether any recall keyword or alias appears in the +// space-normalized prompt on a word boundary. Multi-word terms are supported. +func recallHit(norm string, d Domain) bool { + for _, term := range d.Recall { + if termHit(norm, term) { + return true + } + } + for _, term := range d.Aliases { + if termHit(norm, term) { + return true + } + } + return false +} + +func termHit(norm, term string) bool { + t := strings.TrimSpace(nonAlnumRe.ReplaceAllString(strings.ToLower(term), " ")) + if t == "" { + return false + } + return strings.Contains(norm, " "+t+" ") +} + +// Render is the single renderer both front doors use. It emits a header plus one +// section per domain. An empty domain list renders to zero bytes (D3: no +// model-facing tokens on a no-match). +func Render(domains []ResolvedDomain) string { + if len(domains) == 0 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "# abcd rules — %d domain(s) active\n", len(domains)) + for _, d := range domains { + b.WriteString(renderDomain(d)) + } + return b.String() +} + +// renderDomain renders one domain's block deterministically. Signature hashes +// exactly this, so the format is the dedup unit. +func renderDomain(d ResolvedDomain) string { + var b strings.Builder + fmt.Fprintf(&b, "## %s\n", d.Name) + for _, r := range d.Rules { + fmt.Fprintf(&b, "- %s\n", r) + } + return b.String() +} + +// Signature is the per-domain dedup key: an FNV-1a hash of the rendered block, +// so identical rendered content (defaults or override) dedups and any content +// drift invalidates. FNV is sufficient — this is dedup, not security. +func Signature(d ResolvedDomain) string { + h := fnv.New64a() + _, _ = h.Write([]byte(renderDomain(d))) + return fmt.Sprintf("%016x", h.Sum64()) +} + +func cloneRuleSet(rs RuleSet) RuleSet { + out := RuleSet{SchemaVersion: rs.SchemaVersion, Disabled: rs.Disabled} + if rs.Domains != nil { + out.Domains = make(map[string]Domain, len(rs.Domains)) + for name, d := range rs.Domains { + out.Domains[name] = Domain{ + State: d.State, + Recall: append([]string(nil), d.Recall...), + Aliases: append([]string(nil), d.Aliases...), + Rules: append([]string(nil), d.Rules...), + } + } + } + return out +} diff --git a/internal/core/rules/rules_test.go b/internal/core/rules/rules_test.go new file mode 100644 index 00000000..675f8a9d --- /dev/null +++ b/internal/core/rules/rules_test.go @@ -0,0 +1,269 @@ +package rules + +import ( + "os" + "path/filepath" + "testing" +) + +func names(ds []ResolvedDomain) []string { + out := make([]string, len(ds)) + for i, d := range ds { + out[i] = d.Name + } + return out +} + +func has(ds []ResolvedDomain, name string) bool { + for _, d := range ds { + if d.Name == name { + return true + } + } + return false +} + +func TestDefaultsParseAndValidate(t *testing.T) { + rs := Defaults() + if rs.SchemaVersion != 1 { + t.Fatalf("schema_version = %d, want 1", rs.SchemaVersion) + } + if err := Validate(rs); err != nil { + t.Fatalf("bundled defaults fail validation: %v", err) + } + for _, want := range []string{"COMMITTING", "DOCUMENTATION", "ROADMAP", "ISSUES", "INTENTS", "LIFEBOAT", "PII"} { + if _, ok := rs.Domains[want]; !ok { + t.Errorf("default domain %q missing", want) + } + } +} + +func TestMatchRecallKeyword(t *testing.T) { + rs := Defaults() + got := rs.Match("let's commit and push this") + if !has(got, "COMMITTING") { + t.Fatalf("expected COMMITTING, got %v", names(got)) + } +} + +func TestMatchMultiWordAlias(t *testing.T) { + rs := Defaults() + got := rs.Match("please open the pull request now") + if !has(got, "COMMITTING") { + t.Fatalf("expected COMMITTING via multi-word alias, got %v", names(got)) + } +} + +func TestMatchNoHitInjectsNothing(t *testing.T) { + rs := Defaults() + got := rs.Match("render a react component with a gradient background") + if len(got) != 0 { + t.Fatalf("expected zero domains on no-match, got %v", names(got)) + } +} + +func TestMatchWordBoundaryNoSubstringFalsePositive(t *testing.T) { + rs := Defaults() + // "scommitted" must not trigger COMMITTING's "commit" keyword. + got := rs.Match("the discommitted witticism") + if has(got, "COMMITTING") { + t.Fatalf("substring false positive: %v", names(got)) + } +} + +func TestStarCommandActivatesRegardlessOfKeyword(t *testing.T) { + rs := Defaults() + got := rs.Match("*ROADMAP draft the next milestone") + if !has(got, "ROADMAP") { + t.Fatalf("star-command did not activate ROADMAP: %v", names(got)) + } +} + +func TestStarCommandBoundaries(t *testing.T) { + // Synthetic domain whose name is NOT one of its recall keywords, so a hit can + // only come from star-command parsing (not incidental recall on the name). + rs := RuleSet{SchemaVersion: 1, Domains: map[string]Domain{ + "ZONK": {State: StateActive, Recall: []string{"zzznomatch"}, Rules: []string{"r"}}, + }} + // Positive control: a well-formed star-command activates. + if !has(rs.Match("*ZONK do the thing"), "ZONK") { + t.Fatal("well-formed *ZONK not activated") + } + // Boundary rejections: star preceded by a non-space, glued to a longer token, + // or not an uppercase name are NOT star-commands. + for _, p := range []string{"path/*ZONK", "e*ZONK", "*ZONKING now", "list *.py files", "* ZONK bullet"} { + if got := rs.Match(p); has(got, "ZONK") { + t.Errorf("%q wrongly parsed as a star-command: %v", p, names(got)) + } + } +} + +func TestStarCommandActivatesDormant(t *testing.T) { + rs := Defaults() + d := rs.Domains["ROADMAP"] + d.State = StateDormant + rs.Domains["ROADMAP"] = d + // dormant: no recall activation... + if has(rs.Match("update the roadmap"), "ROADMAP") { + t.Fatal("dormant domain activated by recall") + } + // ...but star-command overrides dormant. + if !has(rs.Match("*ROADMAP go"), "ROADMAP") { + t.Fatal("star-command did not override dormant") + } +} + +func TestKillSwitchSuppressesEverything(t *testing.T) { + rs := Defaults() + rs.Disabled = true + if got := rs.Match("commit and push"); len(got) != 0 { + t.Fatalf("kill switch did not suppress recall: %v", names(got)) + } + // Star-command must NOT bypass the kill switch. + if got := rs.Match("*ROADMAP go"); len(got) != 0 { + t.Fatalf("star-command bypassed the kill switch: %v", names(got)) + } +} + +func TestMergePerFieldOverride(t *testing.T) { + base := Defaults() + over := RuleSet{ + SchemaVersion: 1, + Domains: map[string]Domain{ + "ROADMAP": {State: StateDormant}, // silence, keep recall/rules + "CUSTOM": {State: StateActive, Recall: []string{"widget"}, Rules: []string{"do the thing"}}, + }, + } + merged := Merge(base, over) + // ROADMAP keeps its default recall but is now dormant. + if got := merged.Domains["ROADMAP"]; got.State != StateDormant || len(got.Recall) == 0 { + t.Fatalf("per-field override wrong: state=%q recall=%v", got.State, got.Recall) + } + // CUSTOM added. + if _, ok := merged.Domains["CUSTOM"]; !ok { + t.Fatal("custom domain not merged in") + } + if !has(merged.Match("build a widget"), "CUSTOM") { + t.Fatal("merged custom domain does not recall-match") + } +} + +func TestMergeKillSwitchIsSticky(t *testing.T) { + base := Defaults() + if got := Merge(base, RuleSet{Disabled: true}); !got.Disabled { + t.Fatal("repo override could not enable the kill switch") + } +} + +func TestValidateRejectsBadDomainName(t *testing.T) { + rs := RuleSet{SchemaVersion: 1, Domains: map[string]Domain{"bad-name": {}}} + if err := Validate(rs); err == nil { + t.Fatal("expected validation error for lowercase/hyphen domain name") + } +} + +func TestValidateRejectsBadSchemaVersion(t *testing.T) { + if err := Validate(RuleSet{SchemaVersion: 2}); err == nil { + t.Fatal("expected validation error for schema_version != 1") + } +} + +func TestValidateRejectsBadState(t *testing.T) { + rs := RuleSet{SchemaVersion: 1, Domains: map[string]Domain{"X": {State: "paused"}}} + if err := Validate(rs); err == nil { + t.Fatal("expected validation error for unknown state") + } +} + +func TestLoadAbsentFileReturnsDefaults(t *testing.T) { + dir := t.TempDir() + rs, err := Load(dir) + if err != nil { + t.Fatalf("Load with no rules.json: %v", err) + } + if len(rs.Domains) != len(Defaults().Domains) { + t.Fatalf("absent rules.json should yield defaults, got %d domains", len(rs.Domains)) + } +} + +func TestLoadMergesRepoFile(t *testing.T) { + dir := t.TempDir() + writeRepoRules(t, dir, `{"schema_version":1,"disabled":false,"domains":{"ROADMAP":{"state":"dormant"}}}`) + rs, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if rs.Domains["ROADMAP"].State != StateDormant { + t.Fatal("repo override not applied") + } + if len(rs.Domains["ROADMAP"].Rules) == 0 { + t.Fatal("per-field merge dropped default rules") + } +} + +func TestLoadMalformedFileFailsClosed(t *testing.T) { + dir := t.TempDir() + writeRepoRules(t, dir, `{ this is not json `) + if _, err := Load(dir); err == nil { + t.Fatal("malformed rules.json must fail closed, not silently fall back") + } +} + +func TestRenderContainsDomainsAndHeader(t *testing.T) { + rs := Defaults() + out := Render(rs.Match("commit and push")) + if out == "" { + t.Fatal("render produced nothing for a match") + } + if !contains(out, "COMMITTING") { + t.Fatalf("render missing domain name:\n%s", out) + } +} + +func TestRenderEmptyIsZeroBytes(t *testing.T) { + if out := Render(nil); out != "" { + t.Fatalf("no-match render must be zero bytes (D3), got %q", out) + } +} + +func TestSignatureStableAndDistinct(t *testing.T) { + rs := Defaults() + commit := pick(rs, "COMMITTING") + docs := pick(rs, "DOCUMENTATION") + if Signature(commit) != Signature(commit) { + t.Fatal("signature not stable") + } + if Signature(commit) == Signature(docs) { + t.Fatal("distinct domains share a signature") + } + // Content drift changes the signature. + drift := commit + drift.Rules = append([]string{"a new rule"}, drift.Rules...) + if Signature(drift) == Signature(commit) { + t.Fatal("signature did not change on content drift") + } +} + +func pick(rs RuleSet, name string) ResolvedDomain { + return ResolvedDomain{Name: name, Domain: rs.Domains[name]} +} + +func writeRepoRules(t *testing.T, dir, body string) { + t.Helper() + abcd := filepath.Join(dir, ".abcd") + if err := os.MkdirAll(abcd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(abcd, "rules.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} From 6aa47484b2aefebc8312ec2fe5f4266dcbd6bdef Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:33:20 +0100 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20itd-3=20phase=203=20=E2=80=94=20abc?= =?UTF-8?q?d=20rules=20[domain]=20verb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the rules loader core to its vendor-neutral front door. Bare `abcd rules` renders the active rule set; a positional DOMAIN (case-insensitive) scopes to one domain; an unknown domain exits 2; --json emits the structured envelope. A malformed .abcd/rules.json fails closed. Adds core Active()/Lookup() helpers. The capability is now reachable and demonstrable from the production CLI entry point without any harness. The prompt-router hook (phase 2) injects the same core just-in-time next. Tests watched fail then pass; make preflight + record-lint green. Assisted-by: Claude:claude-opus-4-8 --- CHANGELOG.md | 9 +++++ internal/core/rules/rules.go | 33 +++++++++++++++++- internal/core/rules/rules_test.go | 32 ++++++++++++++++++ internal/surface/cli/cli.go | 56 +++++++++++++++++++++++++++++++ internal/surface/cli/cli_test.go | 48 ++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0db4b1..8be23971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ called out in a **Breaking** section. ### Added +- The **modular rules loader** core and its `abcd rules [domain]` verb (itd-3, + phases 1 + 3). `internal/core/rules` holds binary-bundled default rule domains + (COMMITTING, DOCUMENTATION, ROADMAP, ISSUES, INTENTS, LIFEBOAT, PII) merged + with an optional per-repo `.abcd/rules.json` override (per-field domain + override, sticky kill switch), with word-bounded recall matching, `*` + star-commands, and per-domain dedup signatures. Bare `abcd rules` renders the + active rule set; a positional `DOMAIN` (case-insensitive) scopes to one; a + malformed `rules.json` fails closed. The prompt-router hook that injects these + just-in-time is the next phase — the verb is the vendor-neutral front door. - A `surface_coverage` record-lint rule (iss-35): the deterministic half of the brief↔surface cross-check. It reads the plugin surface (`rules.surface_coverage.commands_dir`, `skills_dir` — outside the lint roots) diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index 7445a989..148b8687 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -57,7 +57,7 @@ type RuleSet struct { // ResolvedDomain pairs a domain with its name for ordered rendering and dedup. type ResolvedDomain struct { - Name string + Name string `json:"name"` Domain } @@ -206,6 +206,37 @@ func (rs RuleSet) Match(prompt string) []ResolvedDomain { return out } +// Active returns every injectable domain (state != dormant) in name-sorted +// order — the full set the diagnostic `abcd rules` render shows. The top-level +// kill switch yields nothing. +func (rs RuleSet) Active() []ResolvedDomain { + if rs.Disabled { + return nil + } + names := make([]string, 0, len(rs.Domains)) + for name := range rs.Domains { + names = append(names, name) + } + sort.Strings(names) + var out []ResolvedDomain + for _, name := range names { + if d := rs.Domains[name]; d.State != StateDormant { + out = append(out, ResolvedDomain{Name: name, Domain: d}) + } + } + return out +} + +// Lookup returns one domain by name regardless of its state (a dormant domain is +// still inspectable); ok is false when the name is absent. +func (rs RuleSet) Lookup(name string) (ResolvedDomain, bool) { + d, ok := rs.Domains[name] + if !ok { + return ResolvedDomain{}, false + } + return ResolvedDomain{Name: name, Domain: d}, true +} + // parseStarCommands extracts the set of * names, enforcing that the star // is at the start or preceded by whitespace and the name is followed by the end // or whitespace (the RE2-safe form of the pinned lookahead boundary). diff --git a/internal/core/rules/rules_test.go b/internal/core/rules/rules_test.go index 675f8a9d..66d8ca9f 100644 --- a/internal/core/rules/rules_test.go +++ b/internal/core/rules/rules_test.go @@ -244,6 +244,38 @@ func TestSignatureStableAndDistinct(t *testing.T) { } } +func TestActiveExcludesDormantAndKillSwitch(t *testing.T) { + rs := Defaults() + full := len(rs.Active()) + if full != len(rs.Domains) { + t.Fatalf("Active() = %d, want all %d default domains", full, len(rs.Domains)) + } + d := rs.Domains["PII"] + d.State = StateDormant + rs.Domains["PII"] = d + if got := len(rs.Active()); got != full-1 { + t.Fatalf("dormant domain still active: %d", got) + } + if has(rs.Active(), "PII") { + t.Fatal("Active() returned a dormant domain") + } + rs.Disabled = true + if got := rs.Active(); got != nil { + t.Fatalf("kill switch: Active() = %v, want nil", names(got)) + } +} + +func TestLookup(t *testing.T) { + rs := Defaults() + if _, ok := rs.Lookup("NOSUCH"); ok { + t.Fatal("Lookup returned ok for an absent domain") + } + rd, ok := rs.Lookup("PII") + if !ok || rd.Name != "PII" || len(rd.Rules) == 0 { + t.Fatalf("Lookup(PII) = %+v ok=%v", rd, ok) + } +} + func pick(rs RuleSet, name string) ResolvedDomain { return ResolvedDomain{Name: name, Domain: rs.Domains[name]} } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 2e6dc87f..29f04ba1 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -23,6 +23,7 @@ import ( "github.com/REPPL/abcd-cli/internal/core/launch" "github.com/REPPL/abcd-cli/internal/core/lint" "github.com/REPPL/abcd-cli/internal/core/memory" + "github.com/REPPL/abcd-cli/internal/core/rules" "github.com/spf13/cobra" ) @@ -116,6 +117,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newCaptureCommand(&asJSON)) root.AddCommand(newMemoryCommand(&asJSON)) + root.AddCommand(newRulesCommand(&asJSON)) root.AddCommand(newHistoryCommand(&asJSON)) root.AddCommand(newDocsCommand(&asJSON)) @@ -200,6 +202,60 @@ func newDocsCommand(asJSON *bool) *cobra.Command { return docsCmd } +// rulesView is the machine-readable envelope for bare `abcd rules`: the kill +// switch plus the active domains. +type rulesView struct { + Disabled bool `json:"disabled"` + Domains []rules.ResolvedDomain `json:"domains"` +} + +// newRulesCommand builds the `rules` verb — the vendor-neutral front door onto +// internal/core/rules (itd-3). Bare `abcd rules` renders the active rule set; +// a positional DOMAIN scopes to one domain (case-insensitive). Read-only, +// diagnostic — it never mutates and there is no `show` sub-verb (the positional +// argument is the scope, per the bare-command-as-render discipline). +func newRulesCommand(asJSON *bool) *cobra.Command { + return &cobra.Command{ + Use: "rules [domain]", + Short: "Render the active rule set; a positional DOMAIN scopes to one (read-only)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + rs, err := rules.Load(cwd) + if err != nil { + return err + } + // Scoped: render one domain regardless of its state. + if len(args) == 1 { + name := strings.ToUpper(args[0]) + d, ok := rs.Lookup(name) + if !ok { + return &exitError{Code: 2, Msg: fmt.Sprintf("abcd rules: unknown domain %q", name)} + } + return render(cmd.OutOrStdout(), *asJSON, d, func(w io.Writer) { + fmt.Fprint(w, rules.Render([]rules.ResolvedDomain{d})) + }) + } + // Bare: render the full active set. + active := rs.Active() + return render(cmd.OutOrStdout(), *asJSON, rulesView{Disabled: rs.Disabled, Domains: active}, func(w io.Writer) { + if rs.Disabled { + fmt.Fprintln(w, "abcd rules — disabled (kill switch set in .abcd/rules.json)") + return + } + if out := rules.Render(active); out != "" { + fmt.Fprint(w, out) + return + } + fmt.Fprintln(w, "abcd rules — no active domains") + }) + }, + } +} + // newAhoyCommand builds the `ahoy` sub-tree. Bare `ahoy` runs the read-only // detection pass (abcd's convention: bare invocation never mutates); the // install/uninstall/doctor/dry-run sub-verbs are thin consumers of the same diff --git a/internal/surface/cli/cli_test.go b/internal/surface/cli/cli_test.go index 11e304e4..2ecbc1e1 100644 --- a/internal/surface/cli/cli_test.go +++ b/internal/surface/cli/cli_test.go @@ -45,6 +45,54 @@ func TestBareStatusJSON(t *testing.T) { } } +func TestRulesBareText(t *testing.T) { + out := string(runCLI(t, "rules")) + for _, want := range []string{"COMMITTING", "PII"} { + if !strings.Contains(out, want) { + t.Fatalf("bare `rules` missing %q:\n%s", want, out) + } + } +} + +func TestRulesBareJSON(t *testing.T) { + out := runCLI(t, "rules", "--json") + var got struct { + Disabled bool `json:"disabled"` + Domains []map[string]any `json:"domains"` + } + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("rules --json not JSON: %v\n%s", err, out) + } + if len(got.Domains) == 0 { + t.Fatalf("rules --json returned no domains: %s", out) + } + found := false + for _, d := range got.Domains { + if d["name"] == "COMMITTING" { + found = true + } + } + if !found { + t.Fatalf("rules --json missing COMMITTING: %s", out) + } +} + +func TestRulesScopedUppercasesArg(t *testing.T) { + out := string(runCLI(t, "rules", "committing")) + if !strings.Contains(out, "COMMITTING") { + t.Fatalf("scoped `rules committing` missing COMMITTING:\n%s", out) + } + if strings.Contains(out, "## PII") { + t.Fatalf("scoped render leaked another domain:\n%s", out) + } +} + +func TestRulesUnknownDomainErrors(t *testing.T) { + if _, err := runCLIErr(t, "rules", "nosuch"); err == nil { + t.Fatal("unknown domain must exit non-zero") + } +} + // validHooksJSON is a structurally-sound plugin hook manifest for the hermetic // plugin root, so the install path's hook-manifest verification passes. const validHooksJSON = `{ From c42d6bff2b9af36b0e6b5275634a8bed1d435085 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:41:03 +0100 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20itd-3=20phase=202=20=E2=80=94=20pro?= =?UTF-8?q?mpt-router=20hook=20(trust=20boundary)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code injection front door onto internal/core/rules. Two operator- internal entrypoints — `abcd hook prompt-router` (UserPromptSubmit) and `abcd hook prompt-router-reset` (SessionStart/PreCompact) — read the hook stdin payload, recall-match, dedup per session, and inject only fresh matched rules. Core additions (pure, transport-free): Inject() with per-domain signature dedup and a fixed-N refresh backstop (default 15; event-driven reset is primary per D1), and session-state persistence (LoadState/SaveState/ResetState) keyed on a sha256 of the session id so a hostile id cannot traverse the state dir. Trust boundary hardening (security-reviewer: PASS-WITH-NOTES, no blocker): Load now refuses a symlinked/non-regular/oversized rules.json; stdin (1 MiB), prompt (64 KiB), and state file (256 KiB) are capped; the injected text is abcd's own rule content only and never reflects prompt bytes; the reset diagnostic quotes the untrusted event name. Every path is fail-closed and non-blocking (exit 0 + out-of-band stderr diagnostic); a no-match renders zero model-facing tokens (D3). Tests watched fail then pass; make preflight + record-lint green. Assisted-by: Claude:claude-opus-4-8 --- CHANGELOG.md | 10 +- internal/core/rules/inject.go | 150 +++++++++++++++++++++++++++++ internal/core/rules/inject_test.go | 129 +++++++++++++++++++++++++ internal/core/rules/rules.go | 21 +++- internal/surface/cli/cli.go | 116 ++++++++++++++++++++++ internal/surface/cli/cli_test.go | 84 ++++++++++++++++ 6 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 internal/core/rules/inject.go create mode 100644 internal/core/rules/inject_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be23971..3095d25b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,14 @@ called out in a **Breaking** section. override, sticky kill switch), with word-bounded recall matching, `*` star-commands, and per-domain dedup signatures. Bare `abcd rules` renders the active rule set; a positional `DOMAIN` (case-insensitive) scopes to one; a - malformed `rules.json` fails closed. The prompt-router hook that injects these - just-in-time is the next phase — the verb is the vendor-neutral front door. + malformed `rules.json` fails closed. A Claude Code prompt-router hook + (`abcd hook prompt-router` / `prompt-router-reset`, operator-internal) injects + the matched rules just-in-time on `UserPromptSubmit` with per-session + signature dedup, clears the ledger on a `SessionStart`/`PreCompact` reset + (event-driven refresh; a large fixed-N counter is only a backstop), and is + fail-closed and non-blocking — a malformed payload, unreadable `rules.json`, + or state error injects nothing and logs out-of-band, never wedging a session. + The `hooks/hooks.json` manifest wiring lands with ahoy in the next phase. - A `surface_coverage` record-lint rule (iss-35): the deterministic half of the brief↔surface cross-check. It reads the plugin surface (`rules.surface_coverage.commands_dir`, `skills_dir` — outside the lint roots) diff --git a/internal/core/rules/inject.go b/internal/core/rules/inject.go new file mode 100644 index 00000000..68c357be --- /dev/null +++ b/internal/core/rules/inject.go @@ -0,0 +1,150 @@ +package rules + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" +) + +// maxPromptBytes bounds how much prompt text is scanned for recall, so a huge +// pasted prompt cannot blow up matching (trust boundary). +const maxPromptBytes = 64 * 1024 + +// maxStateFileBytes caps a session-state ledger file on read. +const maxStateFileBytes = 256 * 1024 + +// DefaultRefreshBackstop is the fixed-N full-refresh backstop (D1): the primary +// refresh is event-driven (a SessionStart/PreCompact reset clears the ledger), +// and this large counter only catches always-relevant domains that never +// recall-match. It is deliberately larger than CARL's every-5. +const DefaultRefreshBackstop = 15 + +// InjectResult is the outcome of one prompt-router evaluation. Text is empty +// when nothing new is injected (a healthy no-match renders zero model-facing +// tokens, per D3). +type InjectResult struct { + Text string + Injected []string + State SessionState +} + +// SessionState is the per-session dedup ledger plus the prompt counter that +// drives the fixed-N refresh backstop. +type SessionState struct { + Count int `json:"count"` + Ledger map[string]string `json:"ledger"` // domain name -> last-injected signature +} + +// Inject is the pure heart of the prompt-router hook: it recall-matches prompt, +// drops any matched domain already injected this session with an unchanged +// signature, renders the remainder, and returns the updated session state. It +// performs no I/O and never reflects prompt bytes into the output — the rendered +// text is abcd's own rule content only. +// +// backstop is the fixed-N full-refresh interval; <= 0 uses DefaultRefreshBackstop. +// When the (incremented) prompt count is a multiple of the backstop, the ledger +// is cleared first so always-relevant domains re-inject. +func Inject(rs RuleSet, prompt string, prev SessionState, backstop int) InjectResult { + if backstop <= 0 { + backstop = DefaultRefreshBackstop + } + if len(prompt) > maxPromptBytes { + prompt = prompt[:maxPromptBytes] + } + + ledger := map[string]string{} + for k, v := range prev.Ledger { + ledger[k] = v + } + count := prev.Count + 1 + if count%backstop == 0 { + ledger = map[string]string{} + } + + var fresh []ResolvedDomain + var injected []string + for _, d := range rs.Match(prompt) { + sig := Signature(d) + if ledger[d.Name] == sig { + continue // already injected this session, unchanged + } + ledger[d.Name] = sig + fresh = append(fresh, d) + injected = append(injected, d.Name) + } + sort.Strings(injected) + + return InjectResult{ + Text: Render(fresh), + Injected: injected, + State: SessionState{Count: count, Ledger: ledger}, + } +} + +// stateDir is the machine-local directory holding per-session ledgers. It is +// overridable via ABCD_RULES_STATE_DIR (used by tests and by operators who want +// the state off the default temp dir). +func stateDir() string { + if d := os.Getenv("ABCD_RULES_STATE_DIR"); d != "" { + return d + } + return filepath.Join(os.TempDir(), "abcd-rules-state") +} + +// sessionFile maps a session id to a state file. The id is hashed, so an +// attacker-supplied session id can never traverse out of the state dir. +func sessionFile(session string) string { + sum := sha256.Sum256([]byte(session)) + return filepath.Join(stateDir(), hex.EncodeToString(sum[:])+".json") +} + +// LoadState reads the ledger for a session. A missing, oversized, or malformed +// file yields the zero state (a fresh session), never an error — dedup is a +// best-effort optimisation, not a correctness gate. +func LoadState(session string) SessionState { + fi, err := os.Lstat(sessionFile(session)) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxStateFileBytes { + return SessionState{} + } + data, err := os.ReadFile(sessionFile(session)) + if err != nil { + return SessionState{} + } + var st SessionState + if err := json.Unmarshal(data, &st); err != nil { + return SessionState{} + } + if st.Ledger == nil { + st.Ledger = map[string]string{} + } + return st +} + +// SaveState persists the ledger for a session (0700 dir, 0600 file). +func SaveState(session string, st SessionState) error { + if err := os.MkdirAll(stateDir(), 0o700); err != nil { + return err + } + data, err := json.Marshal(st) + if err != nil { + return err + } + tmp := sessionFile(session) + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, sessionFile(session)) +} + +// ResetState clears a session's ledger (the event-driven refresh: the next +// prompt re-injects every matching domain). A missing file is not an error. +func ResetState(session string) error { + err := os.Remove(sessionFile(session)) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/internal/core/rules/inject_test.go b/internal/core/rules/inject_test.go new file mode 100644 index 00000000..3d3e1287 --- /dev/null +++ b/internal/core/rules/inject_test.go @@ -0,0 +1,129 @@ +package rules + +import ( + "strings" + "testing" +) + +func TestInjectFirstTurnRenders(t *testing.T) { + rs := Defaults() + res := Inject(rs, "commit and push", SessionState{}, 0) + if res.Text == "" || !strings.Contains(res.Text, "COMMITTING") { + t.Fatalf("first turn did not inject COMMITTING:\n%s", res.Text) + } + if len(res.Injected) == 0 { + t.Fatal("Injected list empty on a match") + } + if res.State.Count != 1 { + t.Fatalf("count = %d, want 1", res.State.Count) + } +} + +func TestInjectDedupsWithinSession(t *testing.T) { + rs := Defaults() + first := Inject(rs, "commit and push", SessionState{}, 0) + second := Inject(rs, "commit and push again", first.State, 0) + if second.Text != "" { + t.Fatalf("second identical-domain turn re-injected:\n%s", second.Text) + } + if len(second.Injected) != 0 { + t.Fatalf("dedup failed: %v", second.Injected) + } +} + +func TestInjectReinjectsAfterResetState(t *testing.T) { + rs := Defaults() + first := Inject(rs, "commit", SessionState{}, 0) + // A reset clears the ledger; the next turn re-injects. + after := Inject(rs, "commit", SessionState{}, 0) + if after.Text == "" { + t.Fatal("re-inject after cleared ledger produced nothing") + } + _ = first +} + +func TestInjectContentDriftReinjects(t *testing.T) { + rs := Defaults() + first := Inject(rs, "commit", SessionState{}, 0) + // Mutate COMMITTING's rules; the signature changes, so it must re-inject + // despite the ledger entry. + d := rs.Domains["COMMITTING"] + d.Rules = append([]string{"a brand new rule"}, d.Rules...) + rs.Domains["COMMITTING"] = d + second := Inject(rs, "commit", first.State, 0) + if !strings.Contains(second.Text, "a brand new rule") { + t.Fatalf("content drift did not re-inject:\n%s", second.Text) + } +} + +func TestInjectBackstopForcesRefresh(t *testing.T) { + rs := Defaults() + st := SessionState{} + var lastText string + // Backstop of 3: turns 1..2 dedup after the first inject; turn 3 (count%3==0) + // clears the ledger and re-injects. + for i := 0; i < 3; i++ { + res := Inject(rs, "commit", st, 3) + st = res.State + lastText = res.Text + } + if lastText == "" { + t.Fatal("backstop turn did not force a re-inject") + } +} + +func TestInjectNoMatchZeroBytes(t *testing.T) { + rs := Defaults() + res := Inject(rs, "paint a landscape in oils", SessionState{}, 0) + if res.Text != "" { + t.Fatalf("no-match must render zero bytes, got %q", res.Text) + } +} + +func TestInjectNeverReflectsPromptBytes(t *testing.T) { + rs := Defaults() + marker := "ZZUNIQUEPROMPTMARKERZZ commit" + res := Inject(rs, marker, SessionState{}, 0) + if strings.Contains(res.Text, "ZZUNIQUEPROMPTMARKERZZ") { + t.Fatalf("injected text reflected prompt bytes:\n%s", res.Text) + } +} + +func TestSessionStateRoundTripAndReset(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + const sid = "session-abc/../../etc" // traversal attempt; must be neutralised + if got := LoadState(sid); got.Count != 0 { + t.Fatal("fresh session should load zero state") + } + want := SessionState{Count: 4, Ledger: map[string]string{"COMMITTING": "deadbeef"}} + if err := SaveState(sid, want); err != nil { + t.Fatalf("SaveState: %v", err) + } + got := LoadState(sid) + if got.Count != 4 || got.Ledger["COMMITTING"] != "deadbeef" { + t.Fatalf("round-trip mismatch: %+v", got) + } + if err := ResetState(sid); err != nil { + t.Fatalf("ResetState: %v", err) + } + if LoadState(sid).Count != 0 { + t.Fatal("state survived reset") + } + // A second reset on an absent file is not an error. + if err := ResetState(sid); err != nil { + t.Fatalf("reset of absent state errored: %v", err) + } +} + +func TestSessionFileStaysInStateDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("ABCD_RULES_STATE_DIR", dir) + // A traversal-flavoured id must still hash to a file directly inside dir. + f := sessionFile("../../../../etc/passwd") + if got := strings.TrimSuffix(f[:len(dir)], "/"); got != strings.TrimSuffix(dir, "/") { + t.Fatalf("session file escaped state dir: %s", f) + } + if strings.Contains(f[len(dir):], "..") { + t.Fatalf("session file path contains traversal: %s", f) + } +} diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index 148b8687..b0a4239f 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -32,6 +32,9 @@ import ( // RepoRelPath is the per-repo override file, relative to the repo worktree. const RepoRelPath = ".abcd/rules.json" +// maxRulesFileBytes caps the per-repo rules.json (trust boundary). +const maxRulesFileBytes = 256 * 1024 + // Domain state values. An empty string is treated as active. const ( StateActive = "active" @@ -102,10 +105,26 @@ func Defaults() RuleSet { return cloneRuleSet(defaultRuleSet) } // that cannot be parsed or fails validation is a fail-closed error. func Load(repoRoot string) (RuleSet, error) { path := filepath.Join(repoRoot, ".abcd", "rules.json") - data, err := os.ReadFile(path) + fi, err := os.Lstat(path) if os.IsNotExist(err) { return Defaults(), nil } + if err != nil { + return RuleSet{}, fmt.Errorf("rules: stat %s: %w", RepoRelPath, err) + } + // Trust-boundary guards (mirror the ahoy hook-manifest verifier): refuse a + // symlinked leaf and cap the file size so a hostile rules.json cannot force a + // symlink-follow or a memory blow-up. + if fi.Mode()&os.ModeSymlink != 0 { + return RuleSet{}, fmt.Errorf("rules: %s is a symlink (refusing to follow)", RepoRelPath) + } + if !fi.Mode().IsRegular() { + return RuleSet{}, fmt.Errorf("rules: %s is not a regular file", RepoRelPath) + } + if fi.Size() > maxRulesFileBytes { + return RuleSet{}, fmt.Errorf("rules: %s exceeds the %d-byte cap", RepoRelPath, maxRulesFileBytes) + } + data, err := os.ReadFile(path) if err != nil { return RuleSet{}, fmt.Errorf("rules: reading %s: %w", RepoRelPath, err) } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 29f04ba1..89b830e5 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -118,6 +118,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newCaptureCommand(&asJSON)) root.AddCommand(newMemoryCommand(&asJSON)) root.AddCommand(newRulesCommand(&asJSON)) + root.AddCommand(newHookCommand()) root.AddCommand(newHistoryCommand(&asJSON)) root.AddCommand(newDocsCommand(&asJSON)) @@ -202,6 +203,121 @@ func newDocsCommand(asJSON *bool) *cobra.Command { return docsCmd } +// maxHookStdinBytes caps the hook payload read from stdin (trust boundary). +const maxHookStdinBytes = 1 << 20 // 1 MiB + +// hookInput is the subset of the Claude Code hook stdin payload the rules +// entrypoints read. Unknown fields are ignored. +type hookInput struct { + SessionID string `json:"session_id"` + Cwd string `json:"cwd"` + Prompt string `json:"prompt"` + Source string `json:"source"` + Event string `json:"hook_event_name"` +} + +// readHookInput reads and size-caps the hook stdin payload. +func readHookInput(cmd *cobra.Command) (hookInput, error) { + raw, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxHookStdinBytes)) + if err != nil { + return hookInput{}, err + } + var in hookInput + if err := json.Unmarshal(raw, &in); err != nil { + return hookInput{}, err + } + return in, nil +} + +// hookSession returns a stable session key, defaulting when the harness omits +// the id (the hash in the state layer neutralises any hostile value). +func hookSession(in hookInput) string { + if in.SessionID == "" { + return "default" + } + return in.SessionID +} + +// newHookCommand builds the operator-internal `hook` sub-tree: the Claude Code +// prompt-router entrypoints (itd-3). These are NOT a user surface — they are the +// injection transport, one front door onto internal/core/rules alongside the +// `abcd rules` verb. Every path is fail-closed and NON-blocking: a malformed +// payload, an unreadable rules.json, or a state error injects nothing, logs a +// diagnostic to stderr (out-of-band, per D3), and exits 0 so it can never wedge +// a session. +func newHookCommand() *cobra.Command { + hookCmd := &cobra.Command{ + Use: "hook", + Short: "Claude Code hook entrypoints (operator-internal)", + Hidden: true, + Args: cobra.NoArgs, + } + + // prompt-router — UserPromptSubmit: recall-match, dedup, inject. + hookCmd.AddCommand(&cobra.Command{ + Use: "prompt-router", + Short: "UserPromptSubmit: inject the rules matching the prompt", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + in, err := readHookInput(cmd) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: unreadable hook payload (%v); injecting nothing\n", err) + return nil + } + cwd := in.Cwd + if cwd == "" { + if wd, err := os.Getwd(); err == nil { + cwd = wd + } + } + rs, err := rules.Load(cwd) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: %v; injecting nothing\n", err) + return nil + } + session := hookSession(in) + // backstop 0 -> DefaultRefreshBackstop; config.force_refresh_every_n is + // wired in phase 4 (D1: event-driven refresh is primary, N is a backstop). + res := rules.Inject(rs, in.Prompt, rules.LoadState(session), 0) + if err := rules.SaveState(session, res.State); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: state save failed (%v)\n", err) + } + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: turn %d, injected %d domain(s) %v, %d bytes\n", + res.State.Count, len(res.Injected), res.Injected, len(res.Text)) + if res.Text != "" { + fmt.Fprint(cmd.OutOrStdout(), res.Text) + } + return nil + }, + }) + + // prompt-router-reset — SessionStart / PreCompact: clear the dedup ledger so + // the next prompt re-injects (the event-driven refresh, D1/B2). + hookCmd.AddCommand(&cobra.Command{ + Use: "prompt-router-reset", + Short: "SessionStart/PreCompact: clear the dedup ledger", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + in, err := readHookInput(cmd) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: unreadable reset payload (%v)\n", err) + return nil + } + session := hookSession(in) + if err := rules.ResetState(session); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: reset failed (%v)\n", err) + return nil + } + // %q quotes the untrusted hook_event_name so an embedded newline or + // ANSI escape cannot spoof the operator's diagnostic stream. + fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: reset session (%q)\n", in.Event) + return nil + }, + }) + + return hookCmd +} + // rulesView is the machine-readable envelope for bare `abcd rules`: the kill // switch plus the active domains. type rulesView struct { diff --git a/internal/surface/cli/cli_test.go b/internal/surface/cli/cli_test.go index 2ecbc1e1..4bd9fa09 100644 --- a/internal/surface/cli/cli_test.go +++ b/internal/surface/cli/cli_test.go @@ -93,6 +93,90 @@ func TestRulesUnknownDomainErrors(t *testing.T) { } } +// runHook executes a hook entrypoint with separated streams: stdout is the +// model-facing context (must be empty on a no-match), stderr is the out-of-band +// diagnostic. Hooks exit 0, so an error is a test failure. +func runHook(t *testing.T, stdin string, args ...string) (stdout, stderr string) { + t.Helper() + cmd := NewRootCommand() + var so, se bytes.Buffer + cmd.SetOut(&so) + cmd.SetErr(&se) + cmd.SetIn(strings.NewReader(stdin)) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("hook %v exited non-zero: %v\n%s", args, err, se.String()) + } + return so.String(), se.String() +} + +func hookInputJSON(t *testing.T, session, cwd, prompt string) string { + t.Helper() + b, err := json.Marshal(map[string]string{ + "session_id": session, "cwd": cwd, "prompt": prompt, + "hook_event_name": "UserPromptSubmit", + }) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestHookPromptRouterInjects(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() // no rules.json -> bundled defaults + out, errlog := runHook(t, hookInputJSON(t, "s1", cwd, "commit and push"), "hook", "prompt-router") + if !strings.Contains(out, "COMMITTING") { + t.Fatalf("prompt-router did not inject COMMITTING into context:\n%s", out) + } + if !strings.Contains(errlog, "injected 1 domain") { + t.Fatalf("missing out-of-band diagnostic:\n%s", errlog) + } +} + +func TestHookPromptRouterDedups(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + in := hookInputJSON(t, "s2", cwd, "commit and push") + _, _ = runHook(t, in, "hook", "prompt-router") + out2, _ := runHook(t, in, "hook", "prompt-router") + if out2 != "" { + t.Fatalf("second turn re-injected context (dedup failed):\n%s", out2) + } +} + +func TestHookResetReinjects(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + in := hookInputJSON(t, "s3", cwd, "commit and push") + _, _ = runHook(t, in, "hook", "prompt-router") + _, _ = runHook(t, `{"session_id":"s3","hook_event_name":"SessionStart","source":"compact"}`, "hook", "prompt-router-reset") + out, _ := runHook(t, in, "hook", "prompt-router") + if !strings.Contains(out, "COMMITTING") { + t.Fatalf("post-reset turn did not re-inject:\n%s", out) + } +} + +func TestHookNoMatchZeroStdout(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + cwd := t.TempDir() + out, _ := runHook(t, hookInputJSON(t, "s4", cwd, "paint a landscape"), "hook", "prompt-router") + if out != "" { + t.Fatalf("no-match must produce zero model-facing stdout, got %q", out) + } +} + +func TestHookMalformedStdinExitsZero(t *testing.T) { + t.Setenv("ABCD_RULES_STATE_DIR", t.TempDir()) + // A malformed hook payload must not error the process (non-blocking) and must + // inject nothing. + out, err := runCLIErr(t, "hook", "prompt-router") + if err != nil { + t.Fatalf("malformed/empty stdin should exit 0, got %v", err) + } + _ = out +} + // validHooksJSON is a structurally-sound plugin hook manifest for the hermetic // plugin root, so the install path's hook-manifest verification passes. const validHooksJSON = `{ From e22a6c51e457beceef391a6200e398cd1d60bca1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:45:20 +0100 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20itd-3=20phase=204=20=E2=80=94=20aho?= =?UTF-8?q?y=20wiring=20(manifest=20+=20marker=20+=20verifier=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the loader a live install, not built scaffolding. - Add hooks/hooks.json: UserPromptSubmit -> `abcd hook prompt-router`, SessionStart/PreCompact -> `abcd hook prompt-router-reset`, invoking the plugin's own binary ($CLAUDE_PLUGIN_ROOT/abcd) — Go-only, no shell shim. - Reconcile store.go requiredHookCommand to the Go-subcommand substrings (`hook prompt-router` / `hook prompt-router-reset`), superseding the stale prompt_router_hook/_reset script names; update the two manifest test fixtures. - Add TestShippedHookManifestVerifies so the shipped manifest and the verifier cannot drift. - Rewrite the CLAUDE.md marker block present-tense-accurate: real domains (not placeholders), the override-skeleton shape, star-command + kill-switch + dormant semantics, event-driven reset; drop the retired scripts/abcd schema path and the fn- reference. - Clarify stepRules writes the empty-domains override skeleton (defaults live once in the binary; per-repo overrides per-field — one-canonical-primitive), not a copy of the bundled defaults. Behaviour-preserving manifest reconciliation kept in this wiring commit; make preflight + record-lint green. Assisted-by: Claude:claude-opus-4-8 --- hooks/hooks.json | 13 ++++++ internal/core/ahoy/apply.go | 6 ++- .../ahoy/defaults/claude-md-marker-block.md | 40 ++++++++++--------- internal/core/ahoy/detect_test.go | 14 +++++-- internal/core/ahoy/store.go | 9 +++-- internal/surface/cli/cli_test.go | 6 +-- 6 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 hooks/hooks.json diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 00000000..10b6fb60 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,13 @@ +{ + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router"}]} + ], + "SessionStart": [ + {"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router-reset"}]} + ], + "PreCompact": [ + {"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router-reset"}]} + ] + } +} diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index f8254167..21a92a2d 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -383,7 +383,11 @@ func (a *applyCtx) stepSymlink() { } } -// stepRules writes the bundled default .abcd/rules.json when absent. +// stepRules writes the per-repo .abcd/rules.json override skeleton when absent. +// It is deliberately the empty-domains skeleton, NOT a copy of the bundled +// defaults: the default domains live once in the abcd binary (itd-3), and this +// file only overrides them per-field (one-canonical-primitive). An empty +// domains map inherits every bundled default as-is. func (a *applyCtx) stepRules() { if !a.approved[SafeAutocreate] || !a.has("rules.missing") { return diff --git a/internal/core/ahoy/defaults/claude-md-marker-block.md b/internal/core/ahoy/defaults/claude-md-marker-block.md index ca511360..89eaa61a 100644 --- a/internal/core/ahoy/defaults/claude-md-marker-block.md +++ b/internal/core/ahoy/defaults/claude-md-marker-block.md @@ -7,30 +7,34 @@ ## abcd rule loader -This repository uses the abcd modular rules loader. A `UserPromptSubmit` hook -inspects each prompt, recall-matches it against keyword triggers declared in -`/.abcd/rules.json` (and the plugin-bundled defaults), and injects the -matched domain rules into context — instead of force-loading the full ruleset -on every turn. - -- Active rules: `abcd rules` (or `abcd rules ` to scope). -- Per-repo overrides: edit `/.abcd/rules.json` (validated against - `scripts/abcd/schemas/rules.schema.json`). +This repository uses the abcd modular rules loader. On `UserPromptSubmit`, a hook +recall-matches the prompt against keyword triggers declared in the plugin-bundled +default domains and `/.abcd/rules.json`, and injects only the matched +domain rules into context — instead of force-loading the full ruleset every turn. +A prompt that matches no domain injects nothing (zero added tokens). + +- Inspect rules: `abcd rules` renders the active set; `abcd rules ` + (case-insensitive) scopes to one domain. +- Per-repo overrides: edit `/.abcd/rules.json`. It is + `{"schema_version": 1, "disabled": false, "domains": {}}` — add a domain key to + override a default per-field (e.g. `{"ROADMAP": {"state": "dormant"}}` silences + it while keeping its rules) or to declare a custom domain + (`{"recall": [...], "rules": [...]}`). - Kill switch: set `"disabled": true` at the top of `.abcd/rules.json`. - Explicit activation: start a prompt with `*` (e.g. `*COMMITTING`, - `*PII`) to inject that domain unconditionally — overrides `dormant`. + `*PII`) to inject that domain unconditionally — overrides a `dormant` state, + but never the kill switch. ### Default domains -`COMMITTING`, `DOCUMENTATION`, `ROADMAP`, `ISSUES`, `INTENTS`, `LIFEBOAT`, -`PII`. Each ships with a placeholder rule; the legacy harvest from -`~/ABCDevelopment/.claude/CLAUDE.md` is a follow-up phase (per itd-3). +`COMMITTING`, `DOCUMENTATION`, `ROADMAP`, `ISSUES`, `INTENTS`, `LIFEBOAT`, `PII`. +Each carries recall keywords and its rules, bundled in the abcd binary; a repo +overrides them per-field via `.abcd/rules.json`. ### Reset triggers -`SessionStart` and `PreCompact` clear the dedup state so a fresh session sees -the rules again. Compaction-aware: the hook does not double-inject within a -single session, and resets cleanly across compactions. +`SessionStart` and `PreCompact` clear the per-session dedup ledger, so a matched +domain re-injects on the next prompt (the event-driven refresh that recovers +after compaction). Within a session the hook does not re-inject unchanged rules. -For internals see `.abcd/development/brief/05-internals/03-configuration.md` -and the itd-3 / fn-14 spec. +For internals see `.abcd/development/brief/05-internals/03-configuration.md`. diff --git a/internal/core/ahoy/detect_test.go b/internal/core/ahoy/detect_test.go index e4ffe6d2..e1f4fc69 100644 --- a/internal/core/ahoy/detect_test.go +++ b/internal/core/ahoy/detect_test.go @@ -9,9 +9,9 @@ import ( // validHooksJSON is a structurally-sound plugin hook manifest. const validHooksJSON = `{ "hooks": { - "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "$CLAUDE_PLUGIN_ROOT/hooks/prompt_router_hook"}]}], - "SessionStart": [{"hooks": [{"type": "command", "command": "$CLAUDE_PLUGIN_ROOT/hooks/prompt_router_reset"}]}], - "PreCompact": [{"hooks": [{"type": "command", "command": "$CLAUDE_PLUGIN_ROOT/hooks/prompt_router_reset"}]}] + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router"}]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router-reset"}]}], + "PreCompact": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router-reset"}]}] } }` @@ -174,3 +174,11 @@ func hasGap(gaps []Gap, id string) bool { } return false } + +// TestShippedHookManifestVerifies pins the real hooks/hooks.json against the +// verifier, so the manifest and requiredHookCommand can never drift apart. +func TestShippedHookManifestVerifies(t *testing.T) { + if reason := verifyHookManifest("../../.."); reason != "" { + t.Fatalf("shipped hooks/hooks.json fails verification: %s", reason) + } +} diff --git a/internal/core/ahoy/store.go b/internal/core/ahoy/store.go index 7fd91eb5..531d8156 100644 --- a/internal/core/ahoy/store.go +++ b/internal/core/ahoy/store.go @@ -306,11 +306,12 @@ func writeConfig(cwd string, cfg map[string]any) error { // --------------------------------------------------------------------------- // requiredHookCommand is the substring each event's command must contain. These -// are the Go hook entrypoint names as wired in hooks/hooks.json. +// are the Go hook subcommands (`abcd hook prompt-router` / `prompt-router-reset`) +// as wired in hooks/hooks.json — the loader is a Go subcommand, not a script. var requiredHookCommand = map[string]string{ - "UserPromptSubmit": "prompt_router_hook", - "SessionStart": "prompt_router_reset", - "PreCompact": "prompt_router_reset", + "UserPromptSubmit": "hook prompt-router", + "SessionStart": "hook prompt-router-reset", + "PreCompact": "hook prompt-router-reset", } // verifyHookManifest returns "" when hooks/hooks.json under pluginRoot is diff --git a/internal/surface/cli/cli_test.go b/internal/surface/cli/cli_test.go index 4bd9fa09..b742225d 100644 --- a/internal/surface/cli/cli_test.go +++ b/internal/surface/cli/cli_test.go @@ -181,9 +181,9 @@ func TestHookMalformedStdinExitsZero(t *testing.T) { // plugin root, so the install path's hook-manifest verification passes. const validHooksJSON = `{ "hooks": { - "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "$CLAUDE_PLUGIN_ROOT/hooks/prompt_router_hook"}]}], - "SessionStart": [{"hooks": [{"type": "command", "command": "$CLAUDE_PLUGIN_ROOT/hooks/prompt_router_reset"}]}], - "PreCompact": [{"hooks": [{"type": "command", "command": "$CLAUDE_PLUGIN_ROOT/hooks/prompt_router_reset"}]}] + "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router"}]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router-reset"}]}], + "PreCompact": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook prompt-router-reset"}]}] } }` From 1ce39df47dbba48ac506a51a9d6a903b34525b88 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:47:15 +0100 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20itd-3=20phase=205=20=E2=80=94=20OPI?= =?UTF-8?q?NIONS=20default=20domain=20(pointers,=20not=20copies)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the OPINIONS/CONVENTIONS default domain to the binary-bundled defaults, so every abcd-managed repo inherits it via ahoy. Its rules are short directives that POINT AT the canonical conventions under .abcd/development/principles/ (prefer-sota, one-canonical-primitive, evaluator-outside-the-loop, verifier-selects-gates-decide, loud-staging, script-first-mvp) plus an index rule naming the directory — never a copy of the principle text (one-canonical-primitive). Recall triggers on opinion/convention/principle/ approach/SOTA prompts. No path-resolution surface: the rule text names the file for the agent to read on demand; the hook never resolves or reads it, so there is no traversal vector. Marker block domain list + CHANGELOG updated. Tests watched fail then pass; make preflight + record-lint green. Assisted-by: Claude:claude-opus-4-8 --- CHANGELOG.md | 4 +++- .../ahoy/defaults/claude-md-marker-block.md | 8 ++++--- internal/core/rules/defaults/rules.json | 14 +++++++++++ internal/core/rules/rules_test.go | 23 ++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3095d25b..a21ac39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ called out in a **Breaking** section. - The **modular rules loader** core and its `abcd rules [domain]` verb (itd-3, phases 1 + 3). `internal/core/rules` holds binary-bundled default rule domains - (COMMITTING, DOCUMENTATION, ROADMAP, ISSUES, INTENTS, LIFEBOAT, PII) merged + (COMMITTING, DOCUMENTATION, ROADMAP, ISSUES, INTENTS, LIFEBOAT, PII, and + OPINIONS — whose rules point at the canonical conventions under + `.abcd/development/principles/` rather than copying them) merged with an optional per-repo `.abcd/rules.json` override (per-field domain override, sticky kill switch), with word-bounded recall matching, `*` star-commands, and per-domain dedup signatures. Bare `abcd rules` renders the diff --git a/internal/core/ahoy/defaults/claude-md-marker-block.md b/internal/core/ahoy/defaults/claude-md-marker-block.md index 89eaa61a..b44cd243 100644 --- a/internal/core/ahoy/defaults/claude-md-marker-block.md +++ b/internal/core/ahoy/defaults/claude-md-marker-block.md @@ -27,9 +27,11 @@ A prompt that matches no domain injects nothing (zero added tokens). ### Default domains -`COMMITTING`, `DOCUMENTATION`, `ROADMAP`, `ISSUES`, `INTENTS`, `LIFEBOAT`, `PII`. -Each carries recall keywords and its rules, bundled in the abcd binary; a repo -overrides them per-field via `.abcd/rules.json`. +`COMMITTING`, `DOCUMENTATION`, `ROADMAP`, `ISSUES`, `INTENTS`, `LIFEBOAT`, `PII`, +`OPINIONS`. Each carries recall keywords and its rules, bundled in the abcd +binary; a repo overrides them per-field via `.abcd/rules.json`. `OPINIONS` +points at the canonical conventions under `.abcd/development/principles/` rather +than copying them. ### Reset triggers diff --git a/internal/core/rules/defaults/rules.json b/internal/core/rules/defaults/rules.json index 76a9c7e6..8b845429 100644 --- a/internal/core/rules/defaults/rules.json +++ b/internal/core/rules/defaults/rules.json @@ -70,6 +70,20 @@ "Never put absolute local paths in anything that leaves the machine — use repo-relative paths.", "Never name a private repo in commits, PRs, issues, or docs; describe it generically." ] + }, + "OPINIONS": { + "state": "active", + "recall": ["opinion", "convention", "principle", "approach", "tooling", "adopt", "sota"], + "aliases": ["best practice", "state of the art", "design decision", "which approach", "how do we", "the opinionated"], + "rules": [ + "Prefer SOTA, adversary-filtered: where a choice has a credible state-of-the-art answer that is the presumptive pick, but challenge it for fit against this repo's preferences before adopting — see .abcd/development/principles/prefer-sota.md.", + "One canonical primitive: before adding a generic-smelling primitive, find its canonical home and extend it; never add a third copy — see .abcd/development/principles/one-canonical-primitive.md.", + "Evaluator outside the loop: the reviewer/challenger is independent of whoever proposed the option — see .abcd/development/principles/evaluator-outside-the-loop.md.", + "Verifier selects, gates decide: a verdict is a proposal; the human's adoption is the gate — see .abcd/development/principles/verifier-selects-gates-decide.md.", + "Loud staging: a stage that no-ops or degrades must say so; never manufacture a false green — see .abcd/development/principles/loud-staging.md.", + "Script-first MVP: earn each tool rung; the smallest documented protocol is the MVP before automation — see .abcd/development/principles/script-first-mvp.md.", + "The full set of opinionated conventions lives in .abcd/development/principles/ — read the relevant file rather than guessing the convention." + ] } } } diff --git a/internal/core/rules/rules_test.go b/internal/core/rules/rules_test.go index 66d8ca9f..e43fdd74 100644 --- a/internal/core/rules/rules_test.go +++ b/internal/core/rules/rules_test.go @@ -3,6 +3,7 @@ package rules import ( "os" "path/filepath" + "strings" "testing" ) @@ -31,13 +32,33 @@ func TestDefaultsParseAndValidate(t *testing.T) { if err := Validate(rs); err != nil { t.Fatalf("bundled defaults fail validation: %v", err) } - for _, want := range []string{"COMMITTING", "DOCUMENTATION", "ROADMAP", "ISSUES", "INTENTS", "LIFEBOAT", "PII"} { + for _, want := range []string{"COMMITTING", "DOCUMENTATION", "ROADMAP", "ISSUES", "INTENTS", "LIFEBOAT", "PII", "OPINIONS"} { if _, ok := rs.Domains[want]; !ok { t.Errorf("default domain %q missing", want) } } } +func TestOpinionsDomainPointsAtPrinciplesNotCopies(t *testing.T) { + rs := Defaults() + // Recall on an opinion/convention/SOTA prompt. + if !has(rs.Match("what's the SOTA approach and our convention here"), "OPINIONS") { + t.Fatal("OPINIONS did not recall-match a conventions prompt") + } + op := rs.Domains["OPINIONS"] + // Every rule points at a principle file (one-canonical-primitive): it names a + // path under .abcd/development/principles/, it does not inline the principle. + pointers := 0 + for _, r := range op.Rules { + if strings.Contains(r, ".abcd/development/principles/") { + pointers++ + } + } + if pointers < len(op.Rules)-1 { // allow the one index rule to name the dir + t.Fatalf("OPINIONS rules should point at principle files, got %d/%d", pointers, len(op.Rules)) + } +} + func TestMatchRecallKeyword(t *testing.T) { rs := Defaults() got := rs.Match("let's commit and push this") From 1f173b10bcb26bd09c4ebe509aa4eb5151c21dce Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:53:54 +0100 Subject: [PATCH 7/9] fix: itd-3 wire force_refresh_every_n + address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruthless-reviewer verdict was SHIP (no FIX-FIRST); this closes the two integrity findings and the cheap nits. - Wire the fixed-N backstop to config: rules.LoadBackstop reads rules.force_refresh_every_n from .abcd/config.json (default 15 when unset), and the hook passes it instead of a hardcoded 0 — the documented config field now does something. Corrects the comment that claimed it was already wired, and updates the config brief to the D1 backstop semantics (default 15, event-driven reset is primary). - Rewrite the vacuous reset test into TestInjectReinjectsOnClearedLedger, which threads session state and asserts dedup-then-reinject; add TestLoadBackstop. - SaveState uses os.CreateTemp for a unique temp name (no orphaned fixed .tmp, no same-session writer race; cleans up on rename failure). - LoadState hoists the double session-id hash; comments clarify the id-less session collapse and that scoped `abcd rules DOMAIN` inspects content independent of the kill switch. make preflight + record-lint green. Assisted-by: Claude:claude-opus-4-8 --- .../brief/05-internals/03-configuration.md | 4 +- internal/core/rules/inject.go | 54 ++++++++++++++++--- internal/core/rules/inject_test.go | 52 +++++++++++++++--- internal/surface/cli/cli.go | 15 ++++-- 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index aa757153..0fe12e93 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -90,7 +90,9 @@ Setup metadata is a `meta` block inside `.abcd/config.json`; there is no separat "default_severity": "minor" // default severity when /abcd:capture omits it (per itd-4) }, "rules": { - "force_refresh_every_n": 5 // prompt-router-hook re-injects every N prompts (per itd-3) + "force_refresh_every_n": 15 // prompt-router fixed-N refresh backstop (per itd-3); event-driven + // reset on SessionStart/PreCompact is the primary refresh, so this + // large counter only re-injects always-relevant domains. Default 15. }, "adapters": { // wired-adapter registry (internal/registry), refreshed each ahoy — // records which optional external backends are present per seam diff --git a/internal/core/rules/inject.go b/internal/core/rules/inject.go index 68c357be..de725b8b 100644 --- a/internal/core/rules/inject.go +++ b/internal/core/rules/inject.go @@ -105,11 +105,12 @@ func sessionFile(session string) string { // file yields the zero state (a fresh session), never an error — dedup is a // best-effort optimisation, not a correctness gate. func LoadState(session string) SessionState { - fi, err := os.Lstat(sessionFile(session)) + path := sessionFile(session) + fi, err := os.Lstat(path) if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxStateFileBytes { return SessionState{} } - data, err := os.ReadFile(sessionFile(session)) + data, err := os.ReadFile(path) if err != nil { return SessionState{} } @@ -123,7 +124,9 @@ func LoadState(session string) SessionState { return st } -// SaveState persists the ledger for a session (0700 dir, 0600 file). +// SaveState atomically persists the ledger for a session (0700 dir; the temp +// file CreateTemp makes is 0600). A unique temp name avoids orphaning a fixed +// path and avoids a same-session writer race; a failed rename cleans up. func SaveState(session string, st SessionState) error { if err := os.MkdirAll(stateDir(), 0o700); err != nil { return err @@ -132,11 +135,50 @@ func SaveState(session string, st SessionState) error { if err != nil { return err } - tmp := sessionFile(session) + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { + f, err := os.CreateTemp(stateDir(), "state-*.tmp") + if err != nil { + return err + } + tmp := f.Name() + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, sessionFile(session)); err != nil { + os.Remove(tmp) return err } - return os.Rename(tmp, sessionFile(session)) + return nil +} + +// LoadBackstop reads rules.force_refresh_every_n from /.abcd/config.json +// — the fixed-N full-refresh backstop (D1). Event-driven reset is the primary +// refresh, so this only bounds always-relevant domains; a missing file/key or a +// non-positive value falls back to DefaultRefreshBackstop. +func LoadBackstop(repoRoot string) int { + path := filepath.Join(repoRoot, ".abcd", "config.json") + fi, err := os.Lstat(path) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxRulesFileBytes { + return DefaultRefreshBackstop + } + data, err := os.ReadFile(path) + if err != nil { + return DefaultRefreshBackstop + } + var cfg struct { + Rules struct { + ForceRefreshEveryN int `json:"force_refresh_every_n"` + } `json:"rules"` + } + if err := json.Unmarshal(data, &cfg); err != nil || cfg.Rules.ForceRefreshEveryN <= 0 { + return DefaultRefreshBackstop + } + return cfg.Rules.ForceRefreshEveryN } // ResetState clears a session's ledger (the event-driven refresh: the next diff --git a/internal/core/rules/inject_test.go b/internal/core/rules/inject_test.go index 3d3e1287..a0247a39 100644 --- a/internal/core/rules/inject_test.go +++ b/internal/core/rules/inject_test.go @@ -1,6 +1,8 @@ package rules import ( + "os" + "path/filepath" "strings" "testing" ) @@ -31,15 +33,53 @@ func TestInjectDedupsWithinSession(t *testing.T) { } } -func TestInjectReinjectsAfterResetState(t *testing.T) { +func TestInjectReinjectsOnClearedLedger(t *testing.T) { rs := Defaults() first := Inject(rs, "commit", SessionState{}, 0) - // A reset clears the ledger; the next turn re-injects. - after := Inject(rs, "commit", SessionState{}, 0) - if after.Text == "" { - t.Fatal("re-inject after cleared ledger produced nothing") + if first.Text == "" { + t.Fatal("first turn injected nothing") + } + // Same session, threaded state: dedup suppresses. + second := Inject(rs, "commit", first.State, 0) + if second.Text != "" { + t.Fatalf("dedup failed on threaded state:\n%s", second.Text) + } + // A cleared ledger (what a reset produces) re-injects, even though the prompt + // is unchanged — this is the reset/refresh contract at the unit level. + third := Inject(rs, "commit", SessionState{}, 0) + if third.Text == "" { + t.Fatal("cleared ledger did not re-inject") + } +} + +func TestLoadBackstop(t *testing.T) { + // Absent config -> default. + if got := LoadBackstop(t.TempDir()); got != DefaultRefreshBackstop { + t.Fatalf("absent config: backstop = %d, want %d", got, DefaultRefreshBackstop) + } + // A valid config value is honoured. + dir := t.TempDir() + writeConfig(t, dir, `{"rules":{"force_refresh_every_n":7}}`) + if got := LoadBackstop(dir); got != 7 { + t.Fatalf("config value not read: %d", got) + } + // A non-positive or malformed value falls back to the default. + bad := t.TempDir() + writeConfig(t, bad, `{"rules":{"force_refresh_every_n":0}}`) + if got := LoadBackstop(bad); got != DefaultRefreshBackstop { + t.Fatalf("non-positive value not defaulted: %d", got) + } +} + +func writeConfig(t *testing.T, dir, body string) { + t.Helper() + abcd := filepath.Join(dir, ".abcd") + if err := os.MkdirAll(abcd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(abcd, "config.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) } - _ = first } func TestInjectContentDriftReinjects(t *testing.T) { diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 89b830e5..0e7abc13 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -230,7 +230,10 @@ func readHookInput(cmd *cobra.Command) (hookInput, error) { } // hookSession returns a stable session key, defaulting when the harness omits -// the id (the hash in the state layer neutralises any hostile value). +// the id (the hash in the state layer neutralises any hostile value). The +// harness supplies session_id in practice; the "default" fallback means two +// concurrent id-less sessions would share one dedup ledger — an accepted +// edge-case degradation, never a correctness or safety issue. func hookSession(in hookInput) string { if in.SessionID == "" { return "default" @@ -276,9 +279,9 @@ func newHookCommand() *cobra.Command { return nil } session := hookSession(in) - // backstop 0 -> DefaultRefreshBackstop; config.force_refresh_every_n is - // wired in phase 4 (D1: event-driven refresh is primary, N is a backstop). - res := rules.Inject(rs, in.Prompt, rules.LoadState(session), 0) + // The fixed-N backstop comes from the repo's config (default 15 when + // unset); event-driven reset is the primary refresh (D1). + res := rules.Inject(rs, in.Prompt, rules.LoadState(session), rules.LoadBackstop(cwd)) if err := rules.SaveState(session, res.State); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: state save failed (%v)\n", err) } @@ -344,7 +347,9 @@ func newRulesCommand(asJSON *bool) *cobra.Command { if err != nil { return err } - // Scoped: render one domain regardless of its state. + // Scoped: inspect one domain's configured content regardless of its + // state OR the kill switch — this diagnostic shows what a domain holds, + // not what would inject right now (bare `abcd rules` reports disabled). if len(args) == 1 { name := strings.ToUpper(args[0]) d, ok := rs.Lookup(name) From 982988aac9ba98b99d963c1b3143aa9761003c78 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:54:57 +0100 Subject: [PATCH 8/9] docs: itd-3 rules-loader build learnings note End-of-run record of the design forks, non-obvious decisions, and process learnings from the itd-3 build, so a future session need not re-derive them. Assisted-by: Claude:claude-opus-4-8 --- .../2026-07-11-itd-3-rules-loader-build.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md diff --git a/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md b/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md new file mode 100644 index 00000000..784c7543 --- /dev/null +++ b/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md @@ -0,0 +1,84 @@ +# itd-3 rules-loader build — learnings + +End-of-run note from the autonomous build of the modular rules loader (itd-3), +recording what shaped the design and the non-obvious decisions a future session +would otherwise re-derive. The design of record is +[`../../plans/2026-07-11-itd-3-rules-loader.md`](../../plans/2026-07-11-itd-3-rules-loader.md). + +## What landed + +Five phases, TDD, each a commit on `auto/itd-3-rules-loader-design`: + +1. `internal/core/rules` — transport-free core: binary-embedded default domains, + per-repo `.abcd/rules.json` merge, recall matching, dedup signatures. +2. The prompt-router hook (`abcd hook prompt-router` / `-reset`) — security-reviewed. +3. The `abcd rules [domain]` verb — the vendor-neutral front door. +4. ahoy wiring — `hooks/hooks.json`, verifier reconcile, marker rewrite. +5. The `OPINIONS` default domain — pointers into `principles/`, not copies. + +Plus a review-fix commit wiring `force_refresh_every_n` to config. + +## Non-obvious decisions (why it is shaped this way) + +- **Core capability, hook as one front door — not an adapter seam.** The + host-agnosticism fork resolved by keeping merge/match/dedup/render in a + vendor-neutral core exercised by `abcd rules`, and quarantining the Claude Code + coupling to two thin `abcd hook` shims. This is the repo's existing + core-plus-front-doors shape (like CLI/MCP), not the adr-22 seam model. Rule + loading is one core verb, not a swappable backend. + +- **The per-repo `rules.json` is an override skeleton, not a copy of the + defaults.** `stepRules` writes `{schema_version,disabled,domains:{}}`; empty + `domains` inherits every bundled default. Writing the full defaults per-repo + would duplicate the canonical primitive (the binary-embedded defaults) into + every repo — the exact one-canonical-primitive failure. Merge is per-field, so + `{"ROADMAP":{"state":"dormant"}}` silences a domain while keeping its rules. + +- **Event-driven refresh beats fixed-N (D1).** The real threat to rule + persistence is compaction, not prompt count. A `SessionStart`/`PreCompact` + reset clears the dedup ledger so the next prompt re-injects; the fixed-N + counter is only a large backstop (default 15) for always-relevant domains. + This came from the SOTA verdict, which cited the `SessionStart(source=compact)` + hook as the surgical post-compaction signal. + +- **Zero model-facing tokens on no-match (D3).** `Render(nil)` is empty; the hook + writes nothing to stdout when nothing matches. Observability is out-of-band: a + stderr diagnostic every invocation (turn/injected/bytes). A silently-broken + hook is distinguished from a healthy no-match by the hook exit-code contract + + the diagnostic, not by an always-on context header — which reframed the + intent's "<200-token header" acceptance criterion. + +- **Fail-closed but non-blocking at the trust boundary.** A malformed payload, + unreadable/oversized/symlinked `rules.json`, or state error injects nothing and + logs — but always exits 0, so the loader can never wedge a session. The + injected text is abcd's own rule content only; the prompt is matched, never + reflected. Session ids are sha256-hashed into the state filename, so a hostile + id cannot traverse the state dir. + +- **Star-command boundary without RE2 lookahead.** The pinned + `(?:^|\s)\*([A-Z][A-Z0-9_]*)(?=$|\s)` uses a lookahead Go's RE2 lacks; it is + enforced by matching `\*([A-Z][A-Z0-9_]*)` and hand-checking the surrounding + bytes. Uppercase + boundary is what stops `* bullet`, `*.py`, and `path/*X` + from parsing as commands. + +## Process learnings + +- **The prefer-sota order paid off.** The adversary's fit-challenge surfaced the + duplication and global-source collisions *before* the verdict, so the SOTA read + was fit-aware — and it materially improved the design over CARL in four ways + (event-driven refresh, per-domain content dedup, keyword+alias recall, zero + no-match tokens) rather than rubber-stamping the prior art. + +- **The shipped code carried latent expectations.** `store.go`, + `detect_test.go`, and `config.json`'s `rules` key already encoded a + Python-script hook name and every-N semantics. Reconciling them (Go subcommand + substrings; backstop meaning) was as much of the work as the new code — a + reminder to grep for what the tree already believes before building. + +## Open follow-ups (not blocking) + +- Suffix stemming for recall is deferred behind an eval (false-positive risk). +- Stale session-state files under the temp dir accumulate; a periodic prune could + be added if it ever matters. +- `Load` guards the `rules.json` leaf but not a symlinked `.abcd` directory + component (same trust level as writing the file; noted by the security review). From 53e251fa050581e342a88447e39674142a44e0f8 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:02:18 +0100 Subject: [PATCH 9/9] feat: itd-3 implement deferred follow-ups (stemming, prune, .abcd guard) Implements the three follow-ups rather than deferring them, per maintainer direction. - Recall stemming: inflected forms recall their keyword (commits->commit, pushes->push, issues->issue) via a conservative suffix stemmer. Short tokens are left unstemmed and `-es` only strips after a sibilant root, so the test->attestation over-match the SOTA verdict warned about is closed by a regression test rather than accepted. - PruneState sweeps session-state files older than StateTTL (one week) on SessionStart, bounding temp-dir growth. - Load refuses a symlinked .abcd directory component, not just the rules.json leaf (the residual the security review flagged as out-of-remit). Learnings note updated to present-tense; tests watched fail then pass; make preflight + record-lint green. Assisted-by: Claude:claude-opus-4-8 --- .../2026-07-11-itd-3-rules-loader-build.md | 20 +++-- CHANGELOG.md | 5 +- internal/core/rules/inject.go | 28 ++++++ internal/core/rules/inject_test.go | 25 ++++++ internal/core/rules/rules.go | 90 +++++++++++++++---- internal/core/rules/rules_test.go | 44 +++++++++ internal/surface/cli/cli.go | 2 + 7 files changed, 190 insertions(+), 24 deletions(-) diff --git a/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md b/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md index 784c7543..925a8fab 100644 --- a/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md +++ b/.abcd/development/research/notes/2026-07-11-itd-3-rules-loader-build.md @@ -75,10 +75,18 @@ Plus a review-fix commit wiring `force_refresh_every_n` to config. substrings; backstop meaning) was as much of the work as the new code — a reminder to grep for what the tree already believes before building. -## Open follow-ups (not blocking) +## Recall stemming (conservative, against the default advice) -- Suffix stemming for recall is deferred behind an eval (false-positive risk). -- Stale session-state files under the temp dir accumulate; a periodic prune could - be added if it ever matters. -- `Load` guards the `rules.json` leaf but not a symlinked `.abcd` directory - component (same trust level as writing the file; noted by the security review). +Recall matches inflected forms (`commits`→`commit`, `pushes`→`push`, +`issues`→`issue`) via a small suffix stemmer. The SOTA verdict advised deferring +stemming behind an eval because of over-matching (`test`→`attestation`); the +maintainer directed shipping it. It is deliberately conservative — short tokens +are left unstemmed, `-es` only strips after a sibilant root, and the named +`test`/`attestation` failure mode is a regression test — so the false-positive +surface the verdict warned about is closed rather than accepted. + +## Housekeeping + +Stale per-session ledgers are swept on `SessionStart` (`PruneState`, TTL one +week), so the temp state dir does not grow unbounded. `Load` refuses a symlinked +`.abcd` directory component as well as a symlinked `rules.json` leaf. diff --git a/CHANGELOG.md b/CHANGELOG.md index a21ac39d..1869cd25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,9 @@ called out in a **Breaking** section. OPINIONS — whose rules point at the canonical conventions under `.abcd/development/principles/` rather than copying them) merged with an optional per-repo `.abcd/rules.json` override (per-field domain - override, sticky kill switch), with word-bounded recall matching, `*` - star-commands, and per-domain dedup signatures. Bare `abcd rules` renders the + override, sticky kill switch), with word-bounded recall matching (including a + conservative suffix stemmer so `commits`/`issues` recall their keyword), + `*` star-commands, and per-domain dedup signatures. Bare `abcd rules` renders the active rule set; a positional `DOMAIN` (case-insensitive) scopes to one; a malformed `rules.json` fails closed. A Claude Code prompt-router hook (`abcd hook prompt-router` / `prompt-router-reset`, operator-internal) injects diff --git a/internal/core/rules/inject.go b/internal/core/rules/inject.go index de725b8b..7420864f 100644 --- a/internal/core/rules/inject.go +++ b/internal/core/rules/inject.go @@ -7,8 +7,13 @@ import ( "os" "path/filepath" "sort" + "time" ) +// StateTTL bounds how long a per-session ledger lives before the reset hook +// sweeps it (sessions are one-shot; a week is generous headroom). +const StateTTL = 7 * 24 * time.Hour + // maxPromptBytes bounds how much prompt text is scanned for recall, so a huge // pasted prompt cannot blow up matching (trust boundary). const maxPromptBytes = 64 * 1024 @@ -190,3 +195,26 @@ func ResetState(session string) error { } return err } + +// PruneState removes session-state files older than maxAge, bounding the growth +// of the temp state dir across many sessions. Best-effort housekeeping: a +// missing dir or a per-entry error is ignored. +func PruneState(maxAge time.Duration) { + entries, err := os.ReadDir(stateDir()) + if err != nil { + return + } + cutoff := time.Now().Add(-maxAge) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(stateDir(), e.Name())) + } + } +} diff --git a/internal/core/rules/inject_test.go b/internal/core/rules/inject_test.go index a0247a39..bc96f74b 100644 --- a/internal/core/rules/inject_test.go +++ b/internal/core/rules/inject_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestInjectFirstTurnRenders(t *testing.T) { @@ -71,6 +72,30 @@ func TestLoadBackstop(t *testing.T) { } } +func TestPruneStateRemovesStaleOnly(t *testing.T) { + dir := t.TempDir() + t.Setenv("ABCD_RULES_STATE_DIR", dir) + if err := SaveState("fresh", SessionState{Count: 1}); err != nil { + t.Fatal(err) + } + if err := SaveState("old", SessionState{Count: 1}); err != nil { + t.Fatal(err) + } + // Age the "old" session's file well past the TTL. + oldFile := sessionFile("old") + past := time.Now().Add(-StateTTL - time.Hour) + if err := os.Chtimes(oldFile, past, past); err != nil { + t.Fatal(err) + } + PruneState(StateTTL) + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Fatal("stale state file survived prune") + } + if LoadState("fresh").Count != 1 { + t.Fatal("fresh state file was pruned") + } +} + func writeConfig(t *testing.T, dir, body string) { t.Helper() abcd := filepath.Join(dir, ".abcd") diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index b0a4239f..3deaef74 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -104,6 +104,11 @@ func Defaults() RuleSet { return cloneRuleSet(defaultRuleSet) } // file exists. An absent file yields the defaults unchanged; a present file // that cannot be parsed or fails validation is a fail-closed error. func Load(repoRoot string) (RuleSet, error) { + // Refuse a symlinked .abcd directory component before touching the leaf, so a + // swapped .abcd cannot redirect the read (trust boundary). + if di, err := os.Lstat(filepath.Join(repoRoot, ".abcd")); err == nil && di.Mode()&os.ModeSymlink != 0 { + return RuleSet{}, fmt.Errorf("rules: .abcd is a symlink (refusing to follow)") + } path := filepath.Join(repoRoot, ".abcd", "rules.json") fi, err := os.Lstat(path) if os.IsNotExist(err) { @@ -200,7 +205,7 @@ func (rs RuleSet) Match(prompt string) []ResolvedDomain { return nil } stars := parseStarCommands(prompt) - norm := normalize(prompt) + idx := indexPrompt(prompt) names := make([]string, 0, len(rs.Domains)) for name := range rs.Domains { @@ -218,7 +223,7 @@ func (rs RuleSet) Match(prompt string) []ResolvedDomain { if d.State == StateDormant { continue } - if recallHit(norm, d) { + if idx.hit(d) { out = append(out, ResolvedDomain{Name: name, Domain: d}) } } @@ -278,37 +283,90 @@ func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f' || b == '\v' } -// normalize lowercases prompt, collapses non-alphanumeric runs to single spaces, -// and pads with a leading and trailing space so a term wrapped in spaces matches -// on word boundaries. -func normalize(s string) string { - lowered := strings.ToLower(s) - collapsed := nonAlnumRe.ReplaceAllString(lowered, " ") - return " " + strings.TrimSpace(collapsed) + " " +// promptIndex is a prompt prepared for recall matching once per Match call: a +// space-padded normalized form for word-boundary and multi-word matching, plus a +// set of stemmed single tokens so inflected forms (commits->commit, pushes->push) +// recall-match their keyword. +type promptIndex struct { + padded string // " tok tok " for boundary/phrase matching + stems map[string]bool // stemmed single tokens +} + +// indexPrompt lowercases, collapses non-alphanumeric runs to single spaces, and +// builds the stemmed token set. +func indexPrompt(s string) promptIndex { + collapsed := strings.TrimSpace(nonAlnumRe.ReplaceAllString(strings.ToLower(s), " ")) + idx := promptIndex{padded: " " + collapsed + " ", stems: map[string]bool{}} + for _, tok := range strings.Fields(collapsed) { + idx.stems[stem(tok)] = true + } + return idx } -// recallHit reports whether any recall keyword or alias appears in the -// space-normalized prompt on a word boundary. Multi-word terms are supported. -func recallHit(norm string, d Domain) bool { +// hit reports whether any of a domain's recall keywords or aliases match. +func (idx promptIndex) hit(d Domain) bool { for _, term := range d.Recall { - if termHit(norm, term) { + if idx.termHit(term) { return true } } for _, term := range d.Aliases { - if termHit(norm, term) { + if idx.termHit(term) { return true } } return false } -func termHit(norm, term string) bool { +// termHit matches a single term. A multi-word term is a word-boundary substring +// of the padded prompt; a single token matches on an exact word boundary OR when +// its stem is present, so plural/tense variants recall their keyword. +func (idx promptIndex) termHit(term string) bool { t := strings.TrimSpace(nonAlnumRe.ReplaceAllString(strings.ToLower(term), " ")) if t == "" { return false } - return strings.Contains(norm, " "+t+" ") + if strings.Contains(t, " ") { + return strings.Contains(idx.padded, " "+t+" ") + } + if strings.Contains(idx.padded, " "+t+" ") { + return true + } + return idx.stems[stem(t)] +} + +// stem strips a common English suffix to a root. Short tokens are left untouched +// so acronyms and 2–4 letter keywords (sota, pr, docs) are never over-stemmed — +// the guard that keeps stemming from matching e.g. "test" against "attestation". +func stem(w string) string { + switch { + case len(w) > 5 && strings.HasSuffix(w, "ing"): + return w[:len(w)-3] + case len(w) > 4 && strings.HasSuffix(w, "ed"): + return w[:len(w)-2] + case len(w) > 4 && strings.HasSuffix(w, "es"): + // "es" plural attaches to sibilant roots (boxes->box, pushes->push); + // elsewhere it is root+"s" (issues->issue), so only strip "es" after a + // sibilant, otherwise drop just the trailing "s". + root := w[:len(w)-2] + if hasSibilantSuffix(root) { + return root + } + return w[:len(w)-1] + case len(w) > 3 && strings.HasSuffix(w, "s") && !strings.HasSuffix(w, "ss"): + return w[:len(w)-1] + } + return w +} + +// hasSibilantSuffix reports whether a root takes an "-es" plural (s/x/z/ch/sh). +func hasSibilantSuffix(root string) bool { + for _, suf := range []string{"s", "x", "z", "ch", "sh"} { + if strings.HasSuffix(root, suf) { + return true + } + } + return false } // Render is the single renderer both front doors use. It emits a header plus one diff --git a/internal/core/rules/rules_test.go b/internal/core/rules/rules_test.go index e43fdd74..91bca54c 100644 --- a/internal/core/rules/rules_test.go +++ b/internal/core/rules/rules_test.go @@ -83,6 +83,50 @@ func TestMatchNoHitInjectsNothing(t *testing.T) { } } +func TestRecallStemmingMatchesInflections(t *testing.T) { + rs := Defaults() + // Plural/tense variants recall their keyword: "issues"->issue, "pushes"->push. + if !has(rs.Match("triage the open issues"), "ISSUES") { + t.Fatal("plural 'issues' did not stem-match ISSUES") + } + if !has(rs.Match("nothing pushes to main"), "COMMITTING") { + t.Fatal("'pushes' did not stem-match COMMITTING") + } +} + +func TestRecallStemmingNoOverMatch(t *testing.T) { + // The named failure mode from the SOTA verdict: "test"-stemming must not + // activate on "attestation". A short keyword is left unstemmed. + rs := RuleSet{SchemaVersion: 1, Domains: map[string]Domain{ + "QA": {State: StateActive, Recall: []string{"test"}, Rules: []string{"r"}}, + }} + if has(rs.Match("the attestation manifesto and progress"), "QA") { + t.Fatal("stemming over-matched: 'attestation' activated a 'test' recall") + } + // But genuine inflections still match. + if !has(rs.Match("running the tests now"), "QA") { + t.Fatal("'tests' did not stem-match 'test'") + } + if !has(rs.Match("testing the flow"), "QA") { + t.Fatal("'testing' did not stem-match 'test'") + } +} + +func TestLoadRefusesSymlinkedAbcdDir(t *testing.T) { + dir := t.TempDir() + real := t.TempDir() + if err := os.WriteFile(filepath.Join(real, "rules.json"), + []byte(`{"schema_version":1,"domains":{}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, filepath.Join(dir, ".abcd")); err != nil { + t.Fatal(err) + } + if _, err := Load(dir); err == nil { + t.Fatal("Load followed a symlinked .abcd directory") + } +} + func TestMatchWordBoundaryNoSubstringFalsePositive(t *testing.T) { rs := Defaults() // "scommitted" must not trigger COMMITTING's "commit" keyword. diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 0e7abc13..be8a2c96 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -311,6 +311,8 @@ func newHookCommand() *cobra.Command { fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: reset failed (%v)\n", err) return nil } + // SessionStart is a natural sweep point for stale ledgers. + rules.PruneState(rules.StateTTL) // %q quotes the untrusted hook_event_name so an embedded newline or // ANSI escape cannot spoof the operator's diagnostic stream. fmt.Fprintf(cmd.ErrOrStderr(), "abcd rules: reset session (%q)\n", in.Event)