Documentation
¶
Overview ¶
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 <repoRoot>/.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 *<DOMAIN> 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.
Index ¶
- Constants
- func LoadBackstop(repoRoot string) int
- func PruneState(maxAge time.Duration)
- func Render(domains []ResolvedDomain) string
- func ResetState(session string) error
- func ResolveRoot(cwd string) string
- func SaveState(session string, st SessionState) error
- func Signature(d ResolvedDomain) string
- func Validate(rs RuleSet) error
- type Domain
- type InjectResult
- type Resolution
- type ResolvedDomain
- type RuleSet
- type SessionState
Constants ¶
const ( StateActive = "active" StateDormant = "dormant" )
Domain state values. An empty string is treated as active.
const ( SourceBundled = "bundled" SourceRepo = "repo" )
Source labels for ResolvedDomain.Source: where a domain's effective content came from. A repo override that names a domain — replacing its rules, changing its state, or declaring it outright — makes the domain SourceRepo, conservatively: the effective behaviour is repo-chosen even when only the state moved. The label set is open so a user layer (spc-23) can add its own without renaming these.
const DefaultRefreshBackstop = 15
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 RepoRelPath = ".abcd/rules.json"
RepoRelPath is the per-repo override file, relative to the repo worktree.
const StateTTL = 7 * 24 * time.Hour
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 TrustedRootsDisplay = "~/" + TrustedRootsRelPath
TrustedRootsDisplay is how that file is NAMED in a diagnostic: the tilde form, never the expanded path, so a refusal a user pastes into a shell works and no message carries the developer-identity home path (iss-81, fsutil.RedactHome).
const TrustedRootsRelPath = ".abcd/trusted-roots"
TrustedRootsRelPath is the home-scoped opt-in that re-admits a marker root the caller does not own, relative to the user's home directory.
Variables ¶
This section is empty.
Functions ¶
func LoadBackstop ¶
LoadBackstop reads rules.force_refresh_every_n from <repoRoot>/.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 PruneState ¶
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 Render ¶
func Render(domains []ResolvedDomain) string
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 ResetState ¶
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 ResolveRoot ¶ added in v0.7.1
ResolveRoot resolves the repo root the per-repo configuration under .abcd/ is read from — rules.json and config.json here, guard.json for the shell guard, which shares this resolver so a session's rules and its guard can never come from two different places.
The resolution is bounded at the git working tree (GHSA-vvqc-3mv2-5p49). The toplevel git reports for cwd is resolved FIRST; the walk then runs from cwd upward but stops at that toplevel inclusive, so the nearest .abcd directory INSIDE the tree wins (a repo that disabled a domain, or the whole loader, stays disabled from any nested directory — iss-66/B12) and a tree with no .abcd resolves to its own toplevel. A .abcd above the working tree is never reached: an unbounded walk let a directory planted in the shared temp dir govern the injected rules and disarm the guard of every repository beneath it. The per-repo file is a repo-scope setting by design (the configuration chapter and itd-3 both reject a global rules.json), so the bound is the documented contract, not a new policy.
The exact scope of what that closes: any ancestor a session's own working tree sits inside; a plant in a shared directory that is not a repository — the one-command `: > /tmp/.git` or `mkdir /tmp/.git` beside a planted /tmp/.abcd, which the fallback below refuses because a marker must look like a repository; and a REAL repository planted in a shared ancestor by another uid (`git init /tmp`, or a repository laid in a root-owned mode-1777 directory), which the fallback refuses on OWNERSHIP — see foreignOwnerRefusal, and TrustedRootsRelPath for the explicit opt-in that re-admits a foreign-uid checkout the caller means to trust. One residual stays open, recorded rather than silently assumed shut:
- iss-2609020219198779 — the user-scope ~/.abcd when the home directory is ITSELF a git working tree (dotfiles-in-home). The toplevel for a session in a non-repo directory beneath such a home is the home, so ~/.abcd governs it. Closing it needs a decision on whether a home-directory toplevel is a legitimate configuration scope (spc-23 plans a user layer that would make it one).
"Not a repository" and "a repository git will not answer for" are DIFFERENT outcomes and only the first resolves to cwd with no walk. abcd runs git under an isolated environment (GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1) which also discards the developer's safe.directory exceptions, so rev-parse fails inside a checkout owned by another uid; it fails outright when the host launches the hook with git off its PATH. Collapsing those onto cwd dropped the repository's own configuration layer with no error anywhere: from a subdirectory the root became the subdirectory, so guard.json's hazards became allows, the rules kill switch stopped applying, and the private banlist store went missing. A repo-shaped tree therefore falls back to the .git marker (gitutil.RepoShapedRoot), and only a directory with no repository above it keeps the no-walk cwd: outside a working tree there is no boundary to stop at, and a walk that stops nowhere is the defect.
The marker walk is a NAME check that runs to the filesystem root, so it is not by itself a bound: an unprivileged `: > /tmp/.git` would otherwise hand every session in a plain directory beneath /tmp to a planted /tmp/.abcd, git having refused to answer for a directory that is not a repository. The fallback root is accepted only when its .git is a PLAUSIBLE repository — a directory carrying HEAD, or a regular file beginning "gitdir: " (plausibleRepository below, which states the shapes exactly) — and anything else takes the non-repo route: cwd, no walk, nothing above it consulted. That discriminator is what git reads, not a trust boundary on its own: laying out a real repository still passes it. The trust boundary is the ownership gate that runs next — a marker root whose owner is not the caller is refused, loudly, unless the caller has declared it in the user-scope home (iss-2609020259564193). Both bounds apply only where git will not answer; where it does, the toplevel it named stands whoever owns it.
A nested repository or submodule resolves its own toplevel either way and does not inherit the superproject's .abcd — correct scoping, visible to submodule workflows.
func SaveState ¶
func SaveState(session string, st SessionState) error
SaveState durably persists the ledger for a session through the canonical fsutil.WriteFileAtomic (temp-write, fsync, rename, parent-dir fsync), so the write is crash-safe and a reader sees either the old file or the complete new one. The state dir is pre-created 0700 (WriteFileAtomic's own MkdirAll is a no-op once it exists); the session file is 0600.
func Signature ¶
func Signature(d ResolvedDomain) 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 Validate ¶
Validate checks structural invariants: schema_version == 1, every domain name matches [A-Z][A-Z0-9_]*, every state is active/dormant (or empty), every domain carries at least one rule, and no rule body is empty or whitespace-only. An empty rule body otherwise passes here and renders as a bare contentless "- " bullet in the injected block (iss-2608261550497978), and a domain with no rules at all — an override of {"rules": []}, or a custom domain declared without any — renders as a heading-only "## NAME" block, suppression wearing the domain's name (GHSA-22f8-qf5r-gjgq sibling). Neither silent shape is acceptable; {"state": "dormant"} is the way to silence a domain.
The ruleless-domain rule is a REFUSAL here and a per-domain skip in Load. It refuses here because Validate is what guards the bundled defaults, where a heading-only domain is a build error. Load reaches Validate with the ruleless domains already dropped and a note naming each (see dropRulelessDomains), because failing a repo's whole file — every domain, on an upgrade — is not proportionate to one malformed entry.
Types ¶
type Domain ¶
type Domain struct {
State string `json:"state,omitempty"`
Recall []string `json:"recall,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Rules []string `json:"rules,omitempty"`
}
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 InjectResult ¶
type InjectResult struct {
Text string
Injected []string
Sources map[string]string
State SessionState
}
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). Sources says, per injected name, which layer the domain came from (SourceBundled or SourceRepo): the names alone cannot, and the out-of-band diagnostic must say whose words went into the context.
func Inject ¶
func Inject(rs RuleSet, prompt string, prev SessionState, backstop int) InjectResult
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 (InjectResult) Labels ¶ added in v0.7.1
func (r InjectResult) Labels() []string
Labels returns the injected names in order, each labelled the way the rendered heading is — "PII (repo override)" for a repo-sourced domain, the bare name otherwise — so the diagnostic and the block agree byte for byte.
type Resolution ¶ added in v0.8.0
Resolution is ResolveRoot's answer together with the diagnostics the resolution produced. Notes is empty in the ordinary case; a non-empty entry is a refusal or a downgrade a front door MUST print out of band (stderr, never the injected context), because a resolution that silently declined to read a repository's own configuration is the exact shape the bound exists to prevent (.abcd/development/principles/loud-staging.md).
func Resolve ¶ added in v0.8.0
func Resolve(cwd string) Resolution
Resolve is ResolveRoot with its diagnostics kept. ResolveRoot is the shorthand for the callers that have nowhere to print them.
type ResolvedDomain ¶
ResolvedDomain pairs a domain with its name for ordered rendering and dedup, and with its Source so every consumer can say whose words these are (GHSA-22f8-qf5r-gjgq): the renderer marks a repo-sourced heading, the hook diagnostic labels the name, and `rules --json` carries the field.
type RuleSet ¶
type RuleSet struct {
SchemaVersion int `json:"schema_version"`
Disabled bool `json:"disabled"`
Domains map[string]Domain `json:"domains"`
// contains filtered or unexported fields
}
RuleSet is the merged, validated rule model: the bundled defaults overlaid with the per-repo override.
func Defaults ¶
func Defaults() RuleSet
Defaults returns a deep copy of the binary-bundled default rule set, safe for the caller to mutate.
func Load ¶
Load returns the defaults merged with <repoRoot>/.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 Merge ¶
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). Every domain the override names is recorded as SourceRepo — the one moment the origin is still known, so the renderer and the diagnostics can say so later.
func (RuleSet) Active ¶
func (rs RuleSet) Active() []ResolvedDomain
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 (RuleSet) Lookup ¶
func (rs RuleSet) Lookup(name string) (ResolvedDomain, bool)
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 (RuleSet) Match ¶
func (rs RuleSet) Match(prompt string) []ResolvedDomain
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.
type SessionState ¶
type SessionState struct {
Count int `json:"count"`
Ledger map[string]string `json:"ledger"` // domain name -> last-injected signature
}
SessionState is the per-session dedup ledger plus the prompt counter that drives the fixed-N refresh backstop.
func LoadState ¶
func LoadState(session string) SessionState
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.