Development record
A Machine's Shared Conventions Are Declared Once In The User Scope — And Every Managed Repo Inherits Them
Press Release
abcd gains a user-scope rules layer. The conventions that are yours rather than abcd's — your definition-of-done wording, your attribution example, your house domains — now live once in
~/.abcd/rules.jsonand inject into every repo abcd manages on that machine. They layer between abcd's bundled default domains and each repo's own.abcd/rules.json: the bundled opinions are the floor, your machine conventions refine them, and a repo override still wins. Nothing is discovered, nothing is grouped, and no directory is occupied: the user scope already exists for exactly this kind of machine-local shared state. A machine with no user rules file behaves precisely as it does today.
"I had the same three rules copy-pasted into several repos, and they'd already drifted — one spelled the trailer one way, another another," said Carol, maintaining a set of sibling projects. "The bundled defaults covered most of it, but the places where my house style differs from the tool's were exactly the places I was repeating myself. Now the delta lives in one file. When I change how we word a rule, I change it once, and the repo that genuinely needs to differ still overrides it locally."
Why This Matters
A three-tier audit of a working machine (user-global harness config, the directory holding several sibling repos, and each repo) found the shared conventions duplicated in prose across repos and already drifting — the same attribution rule stated in several places with divergent examples, the same git-safety and definition-of-done beats restated repo by repo — while other repos carried no instruction file at all and inherited none of it.
The honest scope is narrower than that framing suggests, and this is the
correction an adversarial review forced. abcd's plugin-bundled default
domains already ship most of the duplicated prose: COMMITTING carries the
Assisted-by trailer rule, "never Co-Authored-By for AI", "never commit or
push unless asked", and "branch + PR"; PII carries the privacy conventions.
A repo that installs abcd inherits all of it with no new machinery. So the
unguarded repos are not an argument for a new tier — they are an argument for
installing abcd in them.
What genuinely has no home is the delta between abcd's opinions and a
particular machine's house style: the specific wording of a definition of
done, the model-id used in an attribution example, persona names, and any
custom domain a person wants across their own projects but that abcd has no
business bundling for everyone. Today that delta can only be expressed by
editing every repo's .abcd/rules.json, which is precisely the duplication
that drifts.
The user scope is the right home, and it already exists. The brief records
exactly two scopes — user (~/.abcd/) and repo (in-tree .abcd/) — with the
user scope reserved for "state that cannot live in any one repo's tree because
it is shared across every abcd-managed repo on the machine". Machine-wide rule
conventions are that state. This adds a layer to an existing scope rather than
inventing one: no grouping layer, no workspace registry, no directory
designated or occupied — the stance shipped intent itd-40 locked, left
intact. The rule loader's existing Merge already composes layers, so the
mechanism is Merge(Merge(defaults, user), repo).
Scope Conditions
None stated.
Acceptance Criteria
- Given a machine with no
~/.abcd/rules.json, when the rule loader runs in any repo, then the injected rules are byte-identical to today's and no new file or directory is created anywhere — absence is the default and costs nothing. - Given a
~/.abcd/rules.jsondeclaring a domain field that a bundled default domain also declares, when rules inject in a managed repo with no repo-level override of that field, then the user-scope value is injected in place of the bundled default. - Given the same field also overridden in that repo's
.abcd/rules.json, when rules inject, then the repo value wins — precedence is bundled defaults, then user scope, then repo, with each layer overriding per field by the same wholesale replacement semanticsmergeDomainalready applies. - Given a
~/.abcd/rules.jsondeclaring a custom domain abcd does not bundle, when a prompt recall-matches that domain's keywords in any managed repo on the machine, then its rules inject there without that domain being declared in the repo. - Given a malformed, oversize, or symlinked
~/.abcd/rules.json, when the loader reads it, then it is refused through the same trust-boundary guards the repo-scope file already passes, and the failure is reported loudly rather than silently skipped — an unreadable user layer never degrades to a silent partial injection. - Given a repo where rules injected, when the user runs
abcd rules, then each rendered domain shows which layer it came from (bundled, user, or repo), so a rule's provenance is visible rather than inferred. - Given the repo kill switch or a
dormantstate set at repo level, when the user scope declares the same domain active, then the repo's suppression still wins — the existing kill switch is not weakened by a layer above it.
Open Questions
- Surface for editing: is
~/.abcd/rules.jsonhand-edited only, or does a verb scaffold/validate it (abcd rules --user?)? A read-only render of the effective merged set may be enough for the first slice. - Precedence granularity:
mergeDomainreplaces a domain's fields wholesale (a partial rules-array override is not expressible today). Three layers make that coarser grain more visible — is per-field wholesale replacement still the right semantics, or does the third layer justify finer-grained merging? Recorded as a known limitation, not a blocker. - Deferred to a follow-up intent: detecting that a repo's rules duplicate the user layer (so the user can delete the copy). The motivating duplication is near-verbatim with drift, so detection is semantic-similarity work — a host-delegated oracle, not a string match, and too large for this slice.
- Deferred: whether conventions currently living in per-project harness memory should migrate into the user-scope record.
- Out of scope, recorded for clarity: any directory-designated workspace or repo-grouping layer. The maintainer chose the user scope precisely to avoid reversing itd-40; if machine-wide ever proves too coarse a grain, that is a separate intent with its own ADR.
Audit Notes
Fidelity review — receipt rcp-056068d9886e (verifier intent-auditor claude-fable-5-1).
Provenance: intent-auditor@claude-fable-5-1 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:9776608c18287af8c45be5db9e7b5422040542f74fd82352aadad2a98cac5b6f Input attestations: diff:tree at 4c09b5c749de3dc3d1a1e0ab85d9dcd58ffcb4d5 (main lineage, itd-117 shipped)@-;
Acceptance rollup: MET 6 · MET_WITH_CONCERNS 1 · NOT_MET 0 · INCONCLUSIVE 0
Per-criterion verdicts:
- ac-1 — MET: Load returns Defaults() unchanged when neither file is present and readUserLayer treats an absent path as no layer; TestUserLayerAbsentChangesNothing asserts DeepEqual with Defaults(), an unchanged repo merge, and an unchanged user-scope tree listing before and after evidence: internal/core/rules/rules.go:239 — "if !haveUser && !haveRepo {" evidence: internal/core/rules/rules.go:341 — "if refusal == fsutil.DeclarationAbsent && (os.IsNotExist(err)" evidence: internal/core/rules/user_layer_test.go:88 — "Load created something in the user scope"
- ac-2 — MET: the user layer merges onto Defaults() through mergeFrom with SourceUser before the repo layer; TestUserLayerOverridesBundled asserts the user rule is injected in place of the bundled one, the source reads user, and unset fields inherit the bundled value evidence: internal/core/rules/rules.go:244 — "merged = mergeFrom(merged, user, SourceUser)" evidence: internal/core/rules/user_layer_test.go:158 — "func TestUserLayerOverridesBundled(t *testing.T) {"
- ac-3 — MET: the repo layer merges last through the same Merge/mergeDomain path, so per-field wholesale replacement is unchanged; TestRepoLayerOverridesUser asserts the repo's rules win and a field the repo does not set survives from the layer below evidence: internal/core/rules/rules.go:255 — "merged = Merge(merged, repo)" evidence: internal/core/rules/rules.go:563 — "func mergeDomain(base, over Domain) Domain {" evidence: internal/core/rules/user_layer_test.go:183 — "func TestRepoLayerOverridesUser(t *testing.T) {"
- ac-4 — MET: a user-declared custom domain is added by mergeFrom as a new key and recall-matches in a repo with and without a rules file; TestUserCustomDomainInjectsInAnyRepo asserts the match, the injected text and the user-override label evidence: internal/core/rules/rules.go:548 — "out.origins[name] = source" evidence: internal/core/rules/user_layer_test.go:212 — "func TestUserCustomDomainInjectsInAnyRepo(t *testing.T) {"
- ac-5 — MET: readUserLayer reads through fsutil.ReadDeclaration and turns not-regular, writable-by-others, foreign-owner, oversize, symlinked-leaf and symlinked-~/.abcd into errors naming the file; Load returns an empty set with the error, the hook prints it and injects nothing; TestUserLayerRefusalsAreLoud covers malformed, oversize, symlinked leaf, FIFO, symlinked dir and group-writable, and TestUserLayerRefusalIsNotAPartialSet asserts no domains survive a refusal evidence: internal/core/rules/rules.go:335 — "data, refusal, err := fsutil.ReadDeclaration(path, maxRulesFileBytes)" evidence: internal/core/rules/rules.go:344 — "rules: ~/.abcd is a symlink (refusing to follow it to %s)" evidence: internal/core/rules/rules.go:357 — "exceeds the %d-byte cap" evidence: internal/surface/cli/cli.go:1371 — "fmt.Fprintf(cmd.ErrOrStderr(), "abcd %v; injecting nothing\n", err)" evidence: internal/core/rules/user_layer_test.go:240 — "func TestUserLayerRefusalsAreLoud(t *testing.T) {" evidence: internal/core/rules/user_layer_test.go:364 — "func TestUserLayerRefusalIsNotAPartialSet(t *testing.T) {"
- ac-6 — MET_WITH_CONCERNS: every resolved domain carries a Source of bundled, user or repo and the rendered heading labels user and repo overrides; TestProvenanceNamesAllThreeLayers asserts the three sources through Lookup and the two labelled headings. Concern: the text render of
abcd rulesshows a bundled domain bare, so the bundled layer is conveyed by the absence of a marker rather than a word; the explicit bundled value is on the --json surface only. spc-23 AC6 specifies labels for overridden domains, so the shape is the spec's, not a drift evidence: internal/core/rules/rules.go:978 — "func Label(name, source string) string {" evidence: internal/core/rules/rules.go:983 — "return name" evidence: internal/surface/cli/cli.go:1948 — "An untouched bundled domain renders bare and carries "source": "bundled"" evidence: internal/core/rules/user_layer_test.go:382 — "func TestProvenanceNamesAllThreeLayers(t *testing.T) {" evidence: .abcd/development/specs/closed/spc-23-a-machines-shared-conventions-are-declared-once-in-the-user-s.md:82 — "the CLI test asserts the label for a domain overridden at each level" - ac-7 — MET: the repo layer merges after the user layer so its dormant state replaces the user's active one, and the kill switch is sticky through mergeFrom; TestRepoSuppressionBeatsUserActivation asserts a repo-dormant domain stays dormant and a repo kill switch suppresses the user's custom domain evidence: internal/core/rules/rules.go:538 — "out.killedBy = append(out.killedBy, source)" evidence: internal/core/rules/user_layer_test.go:411 — "func TestRepoSuppressionBeatsUserActivation(t *testing.T) {"
Gap audit:
- honoured:
- one user-scope file layers between bundled defaults and the repo file with no new directory occupied evidence: internal/core/rules/rules.go:229 — "func Load(repoRoot string) (RuleSet, error) {"
- a machine with no user rules file behaves precisely as before evidence: internal/core/rules/user_layer_test.go:62 — "func TestUserLayerAbsentChangesNothing(t *testing.T) {"
- an unreadable user layer fails loudly, never as a partial injection evidence: internal/core/rules/user_layer_test.go:364 — "func TestUserLayerRefusalIsNotAPartialSet(t *testing.T) {"
- diverged:
- provenance is visible for every domain: the text render marks user and repo overrides and leaves a bundled domain bare, with the explicit bundled source on --json only evidence: internal/core/rules/rules.go:983 — "return name"
- missing: (none)
Frontmatter
| id | itd-117 |
|---|---|
| type | intent |
| lifecycle | shipped |
| kind | standalone |
| severity | major |
.abcd/development/intents/shipped/itd-117-development-instructions-live-in-the.md
open on GitHub ↗ · commit history ↗
Referenced by11
- iss-2610262031557534itd-117 puts the user-scope rules at ~/.abcd/rules.json; invariant 21 names abcd's user-level home ~/.abcd.noindex.related
- iss-2610262033117994itd-117 puts the user-scope rules file at `~/.abcd/rules.json`; the ahoy chapter's user scope is `~/.abcd.noindex/`, and the file lives thererelated
- iss-2610262034403157itd-117 puts the machine-wide rules layer at ~/.abcd/rules.json; the intent chapter puts the machine layer of layered configuration under ~/.abcd.noindex/.related
- iss-2610262036343040itd-117 puts the user-scope rules file under ~/.abcd/; the history chapter calls ~/.abcd a legacy locationrelated
- iss-2610262037458871itd-117 puts the user-scope rules layer at ~/.abcd/rules.json; 30-inbox says the rules loader reads ~/.abcd.noindex/rules.jsonrelated
- iss-2610262039356482itd-117 puts the user rules layer at `~/.abcd/rules.json`; the configuration chapter puts it under `~/.abcd.noindex/` and treats any entry at `~/.abcd` as a stoprelated
- iss-2610262041346676itd-117 puts the user-scope rules file at `~/.abcd/rules.json`; the memory chapter puts the user scope under `~/.abcd.noindex/`related
- iss-2610262043555411itd-117 puts the user-scope rules at `~/.abcd/rules.json`; itd-77 puts abcd's user-level home at `~/.abcd.noindex/`related
- iss-2610262044555198itd-3 explicitly rejects a personal cross-repo ~/.abcd/rules.json; itd-117 ships exactly that user-scope rules filerelated
- iss-2610262045072539itd-117 puts the machine's shared conventions in ~/.abcd/rules.json; itd-2610030720038073 renames the home to ~/.abcd.noindexrelated
- spc-23development-instructions-live-in-the-right-tier-abcd-managesimplemented by
mentions9
- dcn-26092500000000282026-09-25 — The build's open-question check reads the record's own settled convention and stays fail-closed (lane implementer, autonomous run A, fix round 1 of the loop lane, on iss-2609252053576085; the lane's first ruling, that every list item under `## Open Questions` is a question whatever it says, lived only in its report and refused intents the record had settled). `intent.OpenQuestions` recognises exactly two markers, learned from the 12 planned intents that carry list items there. A section whose first non-blank line is an italic `_All resolved …_`, with at most one count word (`_All four resolved …_`), is settled whole (itd-111, itd-93); `_All but one resolved_` is not. An item explicitly marked resolved or deferred is not a question: a bold span opening with the word (`**Resolved — …**`, `**Deferred**`, `**Deferred to a follow-up intent**`, `**explicitly deferred**`, `**explicit deferral**`) or the word as a label (`resolved:`, `RESOLVED:`, `Deferred:`), anywhere in the item including its continuation lines. Everything else stays a question: an item led `**Open` whatever it goes on to say (itd-60's "Open, and not gating scope"), an item that only points to another record (itd-76's "travel with itd-126"), `**Out of scope …**`, and a question that merely mentions deferral. With the rule, itd-111 and itd-93 pass the check; itd-7, itd-60, itd-65, itd-66, itd-76, itd-82, itd-117, itd-131, itd-2609170822093401 and itd-2609211116005482 still refuse, each on an item the record does not mark settled. Relabelling those items is the product thinker's call through the planning interview, not the loop's.
- dcn-26092600000000062026-09-26 — Three of the rules loader's security records close, and one stays owed to the product thinker (autonomous run A orchestrator's lane brief, taken by the implementer of lane drainS2). (1) The home directory is never a session's repo root (iss-2609020219198779, answering the owed question "is a home-directory git toplevel a legitimate config scope, or excluded outright?" as the brief rules it): its `.abcd/` is the user layer, so the root walk passes over the home and a toplevel that is the home resolves like a non-repo directory. The lane narrowed the brief's "or an ancestor of HOME": a toplevel that contains the home, the shape of a hermetic harness that points `HOME` inside its checkout, stays the root because git vouched for it and its own `.abcd/` is its own; only the stop at the home is removed. A session whose working directory is the home still reads a `.abcd/` there as the working directory's, the posture question recorded on 2026-09-25. (2) A bundled guardrail that an override withholds is named on every load (iss-174): for COMMITTING, LOAD and PII, each bundled recall keyword, alias or rule missing from a list an override set goes to stderr with the file whose list is in force, and the merge stays per field. The other bundled domains are left out because a repository restates them in its own words, and a note on every restatement would bury the one that matters. Still owed to the product thinker: whether security-bearing lists should union with the bundled entries or take a replace-versus-extend marker instead (itd-117's finer-grained-merging follow-up). (3) The foreign-uid refusal says what it still reads (iss-2609251522588539): the note, the configuration chapter and the install how-to now say that a `.abcd/` at the working directory is read, as AGENTS.md has since 0434d475. (4) iss-2609020219265817 is deferred past v0.11.0, not closed. Every CommonMark heading construct in a rule body (ATX on any line, the first line included, setext, and HTML h1-h6) can be closed only by a code-safe rendering that flattens legitimate structure, or by a fence-aware escaper that is complete only by enumeration and changes the raw text the model reads. So "escaped, fenced, or left to the line-start contract" is the product thinker's ruling.
- iss-2609252053576085The build's open_questions check (intent.OpenQuestions, read by internal/core/implement/loop/check.go) counts every list item under '## Open Questions' as a question whatever it says, so it refuses records that follow the record's own settled convention: 12 of 50 planned intents carry list items there, and some are settled by their own words, itd-111's section opening '_All resolved or explicitly deferred at planning_' and itd-93's '_All four resolved_', itd-76's item marked '**explicitly deferred**', itd-60's items marked '**Resolved', itd-117's '**Deferred**' items. 'abcd build next' (itd-2609211116005482) would meet refusals nobody planned, and the ruling lives only in a lane report. Keep the check fail-closed, but read the markers the record actually uses.
- iss-2610262033114573itd-200 keeps the status line's user-level defaults under `~/.abcd/`; the ahoy chapter keeps `statusline.json` in `~/.abcd.noindex/`
- iss-2610262033133842itd-3 rejects a machine-wide rules.json outright, per-repo only; the ahoy chapter's home inventory has one as the user layer of the rules
- iss-2610262036437864The rules-loader intent rejects a user-level rules.json and keeps rules per repository; the guard chapter counts a rules.json in abcd's home folder as part of the user layer.
- iss-2610262045266820itd-3 rejects a user-level rules.json outright; itd-2610030720038073 records that every managed project's block names the home's top-level rules.json
- itd-3abcd Loads Its Own Rules On Demand
- itd-40abcd Knows What Kind of Folder You're In