context-kit — token-economics-aware context management

A stateless agent session pays for context twice. On-demand cost: opening a large SPEC or source file whole when only one section was needed. Standing cost: the always-loaded surface, everything injected into every session (the instructions file, the session-start hook output). Each added line there is a recurring per-session tax that grows silently, because no single session ever sees the trend.

The kit attacks both:

  • an index-first reading toolset;
  • a session-start hook member that assembles a compact brief;
  • a meter tracking the always-loaded surface against a per-iteration baseline, a brevity gate over its governed always-loaded sections, and the close-stage brevity pass that reacts to the meter’s delta;
  • a memory-off gate pair that keeps the ungoverned harness-memory surface disabled rather than left to accrete.

A consumer’s product-shaped surfaces (proto and diagram indexes, its doc roster, its harvest pipeline) stay in the consumer repo (§Out of scope).

Index-first reading

“Index, then read the one you need” — the pattern every tool serves. All three are advisory arms of the gate binary, reached as bash gate-sdk/bin/run-gates.sh --emit <name> …. None joins gates.list or returns a verdict a battery reads: the non-gate class gate-sdk/SPEC.md §The non-gate arm rules. Each is an arm-table member, never a hardcoded flag. For the first and third that is forced: a tool resolving a consumer knob declares it on its table row, and a hardcoded flag has no row, so the knob-file derivation, check-reads-couples and check-gate-substrate-parity could not see what it reads.

  • --emit md-index [paths…] — compact structural index for Markdown: heading hierarchy with line numbers, each heading followed by its section’s first sentence, plus a per-file line count, the cost signal for reading whole or by section. Defaults to the whole tree, minus CONTEXT_KIT_PRUNE_DIRS, its one declared read.

    Four observable properties are the contract, each a place a reimplementation would quietly differ. An index-tests golden (§Testing) pins the first two. The arm’s own crate tests, run by check-crate-arms, pin the last two and the fence asymmetry below them, since the golden corpus has no heading inside a fence and names one explicit file per invocation:

    • Per-file block shape — <repo-relative path> (<N>L), N being the wc -l newline count. Then one indented row per heading, <indent><hashes> <heading>:<lineno>, or <indent><hashes> <heading>:<lineno> — <first sentence> where a first sentence was found. Then a blank line, after the last block too.
    • The first-sentence rule — the first non-blank line after the heading that is outside a fence and is neither a heading nor a --- rule. Markdown link syntax is reduced to its text and *, _ and backticks are stripped. The line is cut at the first ., ! or ?, else at 120 characters.
    • Traversal and order — *.md under the given paths, on find’s own entry model: a symlinked file is an entry, matched by name and read through. CONTEXT_KIT_PRUNE_DIRS matches the leaf basename below each target. A directory above the target, or the target itself, is never pruned, so a checkout under a build/ or dist/ directory indexes whole. A given path crosses into the declared dialect where it enters (gate-sdk/SPEC.md §The crate’s crosser), so a \-spelled target walks, matches and prints in its / spelling. Results run in byte order of the walked path, absolute in the default form, whose target is the repository toplevel. A file the walk reached but cannot open contributes nothing rather than failing the run.
    • The empty case — No Markdown files found in <targets>, the targets being the ones given, defaulting to the repository toplevel, so the default form’s message carries an absolute path.

    The heading scan is deliberately fence-blind where the first-sentence search is not: a ## inside a fenced block is a row. The arm’s own tests record that asymmetry, so it is contract rather than oversight.

  • --emit md-section <file> <heading> — prints one section, from the matched heading to the next heading of the same or higher level. Match is case-insensitive, tolerates a leading § so a spec citation pastes directly, and compares the heading’s text with hashes and surrounding whitespace stripped. A query is therefore a heading name, never a raw heading line. A heading inside a fenced code block is not structure at either end: it opens no section and closes none around it. The companion: find the heading in the index, extract just that body.

    The matcher is this arm’s and the walk is the crate’s. section::sections bounds a section by heading level exactly as this tool does, so the arm masks fenced lines out and hands the masked view to that walk. The fence and match rules stay the matcher’s, and the bounding stays one implementation. Widening section::sections itself would move its other callers to save one function.

    Matching is exact on the heading’s text: empty on a near miss, correct on an exact query, a stated limit rather than a defect.

    Its declared knob roster is empty, since the arm resolves no knob; it is still an arm-table row, because table membership is what makes --emit md-section reach it. Exit 0 prints the section on stdout. Exit 2 prints a diagnostic on stderr for a missing argument, a missing file, and a query matching no heading.

  • --emit pub-index [paths…] — compact public API surface: every public item with kind, name, and line, sorted by kind then name, in a per-file block headed by a count. It is a dispatcher over per-language extractors with three jobs. It walks with md-index’s walk and CONTEXT_KIT_PRUNE_DIRS, sorts by kind then name (bytewise, then the whole row), and formats rows under a <rel> (<count>) block header, which the index-tests goldens assert exactly.

    Resolution is consumer-first (the gates.list precedent). For each enabled language, a file at <CONTEXT_KIT_PUB_LANG_DIR>/<lang>.sh wins if it exists, else the built-in extractor for that language. Otherwise the arm refuses at exit 2, naming the language, the path it looked for and what is built in. A consumer’s rust.sh therefore shadows the built-in Rust grammar.

    A consumer extractor is a sourced bash file defining exactly two names, both read every run: PUB_LANG_GLOBS, the find globs such as *.rs, and pub_lang_extract <file>, emitting unsorted kind name lineno rows. No other extractor-file surface is contract. The arm makes two bash spawns per language: one sources the file and prints PUB_LANG_GLOBS, one sources it and calls pub_lang_extract over the walked file list. Two, because the globs are needed before the walk; one per contract name rather than a private protocol, so the seam a consumer writes against keeps its shape. The per-file framing inside the second spawn is the dispatcher’s own and reaches no consumer name.

    Two extractors are built in. rust takes every pub, pub(crate) and pub(super) item of the eight declared kinds. ts takes TypeScript’s export-declared function, class, interface, type, enum, const, let and var, folds const enum to enum, and takes export default by name or as the literal default. It reads *.ts and *.tsx, .d.ts included as public surface by construction. Both stay grep-grade on the crate’s POSIX ERE matcher, so re-exports (export { x } from) and multi-line declarations are stated limits, not parsed; AST parsing is above the tool’s portability altitude. They are not arms of their own: the dispatcher is their only caller and the rows it consumes their only output contract, so a flag each would mint a second entry point into the emission path.

One traversal-exclusion set, read by both walkers. CONTEXT_KIT_PRUNE_DIRS is the kit’s single exclusion array. The md-index and pub-index arms share one walk over it rather than each holding a private literal that can drift, and both read it as a static knob (§Layout and configuration). Its default carries the harness’s isolated-agent worktree leaf, without which a walker indexes a second full copy of the repository.

The match is on the leaf basename, gate-sdk/SPEC.md §lib/gate.sh’s rule for its own set: pruning a parent path loses coverage silently, because the governed markdown under it is reached by explicit globs no prune touches. gate-sdk’s set also covers a scratch dir and a fixture-corpus dir, both deliberately absent here: this set’s subject is the second copy of the repository, and adding either narrows a corpus with readers of its own, the index-tests goldens among them. The two sets are not one fact spelled twice. gate-sdk/SPEC.md §lib/gate.sh owns that set’s membership and this knob’s default owns this one; deriving this one from the gate library would make context-kit’s exclusion rule a gate-sdk read, so a consumer assigns this knob in its own config instead.

The session-context hook

The session brief is assembled by --hook session-context, a member of the gate binary’s hook arm that context-kit owns (gate-sdk/SPEC.md §The harness-integration arm). templates/settings-sessionstart.json registers it as the harness’s session-start hook in the exec form: the binary GATE_SDK_NATIVE_BIN names under <project-dir> as command, with --hook session-context as args, spelled as the placeholder the adopter resolves at the merge (guard-kit/SPEC.md §The shell guard). Every step degrades silently: the member never exits non-zero.

  • Its stdout is the brief, the text the session-start integration point reads, and it writes no envelope. A knob that cannot resolve is the one exception: every knob is read before the first line, and on a fault the member takes hook::decline, whose stdout is the systemMessage envelope a non-PreToolUse event gets, and prints no brief.
  • It runs at the toplevel the arm resolved for its working directory. Where git answers no repository or skips a nested one the arm’s own decline answers and the member never starts (gate-sdk/SPEC.md §The harness-integration arm), so no brief describes an enclosing repository as this one.
  • A sibling arm is this binary re-exec’d as a child, on §run-gates’ rule for a member. Steps 5 and 6 read the tree in process and the session-role read parses the payload in process, so git and a configured command are its only other children.
  • A layout judgment is a knob, never an edited script: the component mark, the nudges, the footer and a consumer’s own lines are the CONTEXT_KIT_BRIEF_* rows of §Layout and configuration.

A configured command is spawned with no shell. CONTEXT_KIT_STAGE_RULES and each CONTEXT_KIT_BRIEF_COMMANDS element are split on blanks. A value whose first word begins -- is run as arguments to this binary; any other is started as a program, an interpreter a consumer leads with included. Honest limit: the split honours no quoting, no pipeline and no expansion, so a value that relied on a shell is rewritten as one command or as a program the consumer keeps.

A hooks[] edit arms immediately, for every hook registration in the settings file, not just this template’s. The running session and its already-dispatched subagents pick it up with no restart and no re-dispatch, as a newly registered hook’s log filling inside the session that wrote it shows. Arming is not the session brief, so a hook wired mid-session runs without ever having assembled one.

Steps, in order:

  1. Queue index — queue-kit’s queue-index arm through the battery runner’s --emit front-end (queue-kit/SPEC.md §The queue-index arm), collapsing the Deferred section to a tally except on the close and scope stages.
    • The stage comes from the cursor, CONTEXT_KIT_STATE_FILE’s last data line. An empty cursor (no file, or one truncated to its preamble at an iteration boundary) takes the collapsed branch every other stage takes, so the no-cursor window needs no branch and cannot fail the session.
    • Deferred is unpickable and only scope acts on it; the full board also serves close’s backlog review. By the cursor-lag rule below, firing on close hands the full board to the first scope session, which reads close. On any other stage the full listing is pure recurring cost.
    • A configured icebox tier rides both branches as a one-line tally, so the step needs no new routing. A dormant entry is already one line, and the eviction work reads --emit queue-index --icebox-candidates rather than this index.
  2. Dirty-surface pre-run — for each component with uncommitted changes, pre-run --emit pub-index over it, so a resumed session’s editing surface is already in context. A component is a top-level directory holding the directory CONTEXT_KIT_BRIEF_COMPONENT_MARK names; an empty mark turns the step off.
    • The door the brief’s lines name has one spelling across every step, the installer’s speller over GATE_SDK_NATIVE_BIN (gate-sdk/SPEC.md §lib/gate.sh, gate_native_bin_spelled).
  3. Drift line — one --emit <arm> --trend summary line when CONTEXT_KIT_DRIFT_REPORT names a drift report; silently absent otherwise. drift-kit owns the report; the seam is this optional line.
    • Brief commands follow it: each CONTEXT_KIT_BRIEF_COMMANDS element is run on the configured-command rule above, with no stage appended, since an arm reading positionals would take one as an operand. Its stdout is printed with a blank line after it whatever status it exits: a verdict arm reports through its status and its line is the point. An element that prints nothing or cannot be started is silent.
  4. Stage-conditioned nudges — the text file CONTEXT_KIT_BRIEF_NUDGES keys to the cursor’s stage, printed verbatim. A consumer keys one file to each of {predecessor, own stage} (the cursor-lag rule below), so a first-of-stage session and a restarted one both receive it. Which stages get which nudge is consumer judgment, and the kit default prints none. Suppressed when the session-role signal (below) marks the session lead: the nudges are executor-facing.
  5. Memory-off backstop — one warning line when a dir --emit memory-dirs prints holds content, pointing the durable fact at its tracked home (§The memory-off doctrine). check-memory-off fires only at commit; this surfaces pollution at session start. Silent when the dir is empty or absent.
  6. Scratch sweep — reclaim GATE_SDK_TMP_DIR entries older than a day, depth-first so a stale directory goes once it is empty, never touching .gitkeep or the scratch directory’s self-ignore .gitignore (gate-sdk/SPEC.md §Layout and configuration). The line counts the paths removed. The age guard lets a concurrent same-checkout session’s in-flight scratch survive.
  7. Index-reminder footer — the “index first” ritual. CONTEXT_KIT_BRIEF_FOOTER_FILE names a text file printed verbatim; left empty, the built-in footer names the three index arms, its public-surface line spelled with the component mark and omitted when that mark is empty.
  8. Stage-routed craft-rule pointers — when CONTEXT_KIT_STAGE_RULES names a command (doctrine-kit’s --emit stage-rules arm), the current stage’s craft-rule pointer block, a reminder of the craft rules bearing on that stage before the matching action. doctrine-kit owns the emitter and its tag grammar; the seam is this optional step, on the drift-line precedent.
    • The step runs the command on the configured-command rule above, the stage appended as its last argument. Silently absent when the emitter is unset or the stage routes no rules.
    • A configured command exiting non-zero prints one warning line naming the knob and its exit status in the block’s place, so a broken knob shows at session start rather than as a missing block. One that cannot be started reports 127, the status a shell gives it.
    • Keyed on the derived stage directly, not a {predecessor, own stage} set: the pointers are advisory, so over-firing to an adjacent stage costs a few lines. Suppressed for a lead session: the craft rules are executor-facing.
  9. Env profile — when the consumer-local profile file (CONTEXT_KIT_ENV_PROFILE_FILE, §bin/env-probe) exists, the step runs run-gates.sh --emit env-probe to re-probe it, output suppressed so no status line reaches the brief, then emits its whole body verbatim. The re-probe sits inside the same file-present guard, so the probe never seeds a profile the operator did not opt into. Absent file: silent, no re-probe, the harness’s own Platform: line the fallback. The consumer owns the file’s brevity, authoring its gotchas half (the drift-line precedent).
  10. Update notice — the gate binary’s --emit update-notice line, printed as the arm prints it, so a newer Checkwright release is named at session start (installer/SPEC.md §The update notice). Not suppressed for a lead. The arm bounds its own probe and is silent on any failure, so the step can neither fail nor stall a session past that bound.

The cursor-lag rule. The hook runs at session start, before the arriving skill stamps its entry, and that stamp moves the cursor. A first-of-stage session therefore reads the predecessor’s stage. The lag is structural and survives any change of cursor source: the hook reads a value written after it fires. Stage-conditioned output keys on a stage set spanning {predecessor, own stage}. That serves the first-of-stage session, at the accepted cost of over-firing to other sessions sharing a cursor value: a restarted session of a keyed stage, or the first session of the stage after it.

The session-role signal. Stage-conditioned injection keys off the lifecycle cursor, which says nothing about whether the reading session is a lead, a stage session or a manual run. Without a role signal a lead draws executor-facing craft rules at every hook fire, startup plus each compact or resume re-fire. The signal is a session-id-scoped marker file:

  • Producer. /lead’s first step writes one line lead <id>, <id> being the --emit-session-id arm’s value, to CONTEXT_KIT_SESSION_ROLE_FILE (gitignored scratch, default ${GATE_SDK_TMP_DIR:-.tmp}/session-role).
  • Match. The session is lead only when the marker’s id equals the 8-char prefix of its own payload’s session id. It is read from the hook payload, never from CLAUDE_CODE_SESSION_ID, because a subagent is handed its parent’s id in that variable. The named assumption: were the harness to fire SessionStart in subagents, an env-var read would match every stage session to its lead’s marker and invert the suppression onto its intended audience. A concurrent or later top-level session never bleeds, and a stale marker self-invalidates when the id rotates.
  • One stdin read. The payload arrives on stdin and the hook arm reads it whole before the member runs; an absent or unparseable payload is an absent signal. A manual run inheriting an open terminal or pipe blocks on that read: run the hook by hand with stdin from /dev/null.
  • Why top-level scoping suffices. SessionStart does not fire for Task-spawned subagents, so the hook fires only in leads and manual runs, and the identity match discriminates exactly those.
  • Effect. A lead session suppresses the stage-conditioned nudges and the craft-rule pointers; everything else emits unchanged. With no signal the output is byte-identical to the signal-free hook, and the read is guarded like every step.

Accepted limits, not defects:

  • The startup fire precedes /lead by construction: one fire per lead session, against the per-compact and per-resume recurrence that is the actual waste.
  • The marker ages out with the scratch sweep’s day horizon, degrading to absent-signal behavior.
  • The marker lives as long as the lead session, not the iteration, so a consumer boundary ritual wiping gitignored scratch must spare it; otherwise a lead outliving the boundary reverts to absent-signal behavior until it rewrites the marker.
  • The producer inherits --emit-session-id’s CLAUDE_CODE_SESSION_ID dependency. Unset, the newest-transcript fallback in a lead with live subagents returns an agent- prefix no payload matches, and the signal no-ops, costing a suppression and never a correctness property.

Two alternatives are refused. A launch-env var is a perpetual operator ritual whose forgotten export degrades with no signal. Both producers with a precedence rule is two producers and a rule to spec and gate, for a gap one hook fire wide.

Ruled out — lifecycle stamp-id injection. In a top-level session the hook payload’s 8-char session-id prefix equals what lifecycle-kit’s --emit-session-id arm computes, so the hook could inject the stamp id with no shell-out. The parity holds only there and only while the harness sets CLAUDE_CODE_SESSION_ID: a subagent inherits its parent’s id in that variable, while the arm derives the subagent’s own transcript id (its CLAUDE_CODE_CHILD_SESSION branch). The hook does not inject. lifecycle-kit owns its id derivation end to end, and stage skills reading a context-kit-injected value would wire an upstream kit’s protocol to a downstream kit’s hook for ergonomics alone. The trust model gains nothing, since check-stage-evidence already holds the stamped id current. A consumer may add a local informational echo as a brief command; the kit ships none.

bin/env-probe

The env-probe member derives a local machine profile, so a session adapts to the box it runs on (package manager, toolchain versions, absent tools) without those machine facts landing in the public tree. It writes a marker-bounded generated block, <!-- context-kit:env:begin --> / :end through gate-sdk’s shared marker-block writer, into the file CONTEXT_KIT_ENV_PROFILE_FILE names (default ENV.local.md). It replaces an existing block or appends a fresh one, but only when the probed content changed (Cadence, below), so the block’s probe date marks the last real change. The probed half is derivation-first, never hand-maintained.

How it is invoked, and its one knob. It is a non-gate arm (gate-sdk/SPEC.md §The non-gate arm), --emit-env-probe, reached by every caller as run-gates.sh --emit env-probe, and the binary resolves its configuration in process. It is an action that reports: it rewrites the block and prints one line naming what it did, and both its failures exit 2. Its declared knob roster is CONTEXT_KIT_ENV_PROFILE_FILE and nothing else, one of context-kit’s static knobs (§Layout and configuration), so the value has one place and no second default. A hardcoded profile path would ignore every consumer override, the failure gate-sdk/SPEC.md §The non-gate arm names as the difference between working and appearing to.

The marker test is whole-line, on both halves. The presence test guarding the change-detection read and the writer’s own test agree, so a marker inside prose opens no block, as gate-sdk/SPEC.md §lib/inject.sh rules.

What it probes. OS and distro (uname, /etc/os-release); the package manager, the first present in an ordered detection walk over the known managers; each roster member’s version and floor verdict (below); the absent-tools list, roster members PATH does not resolve; and the below-contract list. native/src/toolfloor.rs owns the roster, never restated here. The arm spawns uname, date off unix, and every roster member. The roster is the kit’s own and no consumer shadows it, which keeps this spawn set bounded by the paragraph below rather than by a consumer’s file.

The roster is what doctor verifies, not the set the binary spawns. gate-sdk/SPEC.md §The program roster is that set, and a unit test holds the two in relation:

  • A spawned program with any audience but contributor is on this roster, on GATE_SDK_PROGRAM_FLOOR’s default, or is the payload itself.
  • The binary spawns date and ps off unix only, where they resolve from Git for Windows’ usr/bin, the userland the bash member’s package carries, so they share that package rather than joining the roster. Honest limit: that directory is on PATH under Git for Windows’ bash and need not be for a process the harness starts directly, where a spawn of either finds nothing and its caller degrades. Source-clone arms alone spawn mktemp and cp. The floor keeps all four for the shipped shell’s sake (gate-sdk/SPEC.md §lib/gate.sh).
  • A contributor-side program, such as uname (this arm), tar and npm (the installer packer), rustc, mktemp or cp, carries the contributor audience on the program roster and need not be on either.
  • Every element of this roster is a program-roster member, so the walk never probes a program the binary cannot name.

The roster and its floor axis (native/src/toolfloor.rs). The roster lives beside the predicate that reads it, in the module the whole crate resolves it through, so the probe arm, check-install-toolchain and the installer’s doctor read one array. The module also carries a parser for an array read out of a file: a gate fixture needs a roster it can author, and a fixture path is untrusted input, so a reader takes the array as text and never sources it. It defines PROBE_SET and the predicate below and nothing else. It carries no knob: the roster is the kit’s own dependency set, and a consumer override could only make the contract lie.

A roster element reads <name>[:<min-version>[:<impl-token>[:<audience>]]]. A bare name must be present, with no version constraint, so the floor axis is per-member, never one number demanded of every member. The fields are positional: a member constrained by implementation alone carries an empty min-version field (<name>::<impl-token>), and an empty field means an omitted trailing one, so jq, jq:, jq:: and jq::: are one unconstrained member. No live member carries an implementation token; the axis stays for a member a construct forces onto one implementation. A member gains a floor only where a construct the battery actually runs forces one, recorded with it. A floor nobody’s code forces is not pinned, which stops a version number rotting into an aspiration: de-literalization applied to a version.

The audience axis. The fourth field names whose floor a member is, since the roster has two kinds of reader and one flat array would gate both on all of it. Its value set is closed and kit-owned, as the floor predicate’s verdict set is, and a unit test in toolfloor.rs holds it closed:

  • empty — every adopter. The unmarked case is not spelled, since declaring the complement on every other member would be a roster maintained against itself; the emptiness rule above carries it.
  • contributor — a contributor-side floor with no install-time role.
  • a kit name, or several joined by + — owed where any named kit is selected. Each value is a kit’s directory name, and the test holds each to a kit root the authoring tree carries, since a misspelled name is a condition nothing satisfies and would drop the member from that kit’s floor. The joiner is + because the element stays one shell word and the install page’s parenthetical already splits on ,.
  • registered — owed where a registered gate’s requirement element (the data --needs prints, gate-sdk/SPEC.md §The # graph: manifest) names the member, derived from the registry, never listed. A registered name with no crate REGISTRY row is a consumer-declared shell gate and contributes nothing here, its command being the consumer’s requirement (gate-sdk/SPEC.md §The program roster); the bash it runs under is owed through derived’s fourth arm.
  • derived — the kit list is not spelled: the reader evaluates the member’s own predicate and takes the kit-list arm above on what it returns.
    • The predicate runs over the kit roots, and over the reader’s anchor where it has an arm keyed on the anchor’s own content (its documents, or its registry and gates dir).
    • It is a sentinel, never a kit name, like the two values above, and exists for registered’s reason: a measurable set is a set nothing should maintain (Derivation-first). A value written back and freshness-gated would be a copy, and the reader already holds the kit roots, so nothing is cached.
    • Resolution is the reader’s, not the roster’s: the predicate opens files, and only the caller knows which tree holds the kits a report is about, the payload before an install and the vendored tree after one.
    • A derived audience resolving to nothing is undecided, never not-owed. Two derivations answer nothing: one that read the kit roots and found no surface, and one that reached no kit root to read. The owed-predicate holds no way to tell them apart, and reading either as not owed would fail open on the second. Every reader publishes the resolved kit names rather than the sentinel, and reports an empty resolution as unprobed.
    • The kit-root derivation itself tells the two apart, for the one reader that compares a page against it (docs/install-parity-contracts.md §The install-toolchain parity contract). Kit roots read and none shipping a surface is an answer, the empty kit list. No kit root read beside the floor-holder is no answer.

The owed-predicate. toolfloor answers is this member owed under this selection, a selection being a kit set and a registered gate set, from a closed three-value set:

  • owed — an empty audience; a kit list (the union of its names) sharing a name with the kit set; or registered where some gate-set member’s REGISTRY row names the member.
  • not owed — contributor; a kit list sharing no name with the kit set; or registered with no such row.
  • undecided — a conditional value with no selection to read, or a derived value the selection carries no resolution for.

A derived value resolves to its kit list first and then takes the kit-list arm, so the predicate gained a spelling and no fourth answer. It exists so no consumer-side reader re-implements the rule against a value set it does not own. A consumer-side reader, the installer’s doctor, whose exit status is init’s last precondition, probes and fails only on an owed member, skips a not-owed one, and renders an undecided one unprobed (installer/SPEC.md §doctor). A contributor-side reader, the env-probe arm, walks the roster whole and marks the audience instead, since a contributor-side floor is what it reports on. The field is a grammar axis, never a filter in the reader: a hard-coded exception is a literal de-literalization forbids, and one that re-fires when a second contributor-only member lands.

The constrained members and what forces each:

  • bash:4.3::derived — the floor is the highest construct the shipped shell runs, not the most numerous. The one bash-4.0 construct present is mapfile (gate-sdk’s gate library and test harness). The nameref (local -n, bash 4.3) outranks it: _gate_prebinary_file_value in gate-sdk/lib/gate.sh, which the front-end sources, so 4.3 is the floor of every shell surface sourcing that library. A 4.0 floor would be a fail-open: env-probe would report ok on a 4.2 box the battery fails with an obscure syntax error.

    The audience is derived, not listed, and this is the predicate a kit is measured against. A kit root is in the bash audience when it ships a file the adopter’s host runs with bash: a file under that root, outside gate-tests/ and smoke/, that either

    • carries a bash shebang, or
    • is a shipped settings template whose hook command is spawned with a bash word. This arm keys on the template, not the script it names, because the kit shipping the wiring is what makes the adopter’s harness run bash, whichever root the target lies under.

    Both arms read a spawn: an interpreter is matched as a path component, so #!/usr/bin/env pwsh does not qualify on the sh inside pwsh, and #!/bin/sh does not qualify at all. Prose telling a reader to type a bash command is deliberately not the predicate. That exclusion keeps the derivation decidable rather than contested: the two readings disagree about which kit roots qualify, and since the door sweep (guard-kit/SPEC.md §check-door-binding) no kit ships such prose. Excluding gate-tests/ and smoke/ reuses the contributor boundary the contributor audience draws.

    The third arm is read over the anchor, not per kit root. The kit owning the fence-executing gate (canon-kit/SPEC.md §check-fence-run) joins the audience when the anchor’s fence-run corpus carries a marked fence. A kit list, or registered, would owe bash to every selection carrying that kit, the prose profile included, and the corpus the gate executes lies in no kit root. The owning kit is read off the registry’s owner column, never written as a literal, and joins only when it is among the reader’s kit roots. The corpus and the marker test are the gate’s own functions, so the arm and the gate cannot disagree about whether a spawn can happen. A marked fence is not the typed prose excluded above: the gate executes it, so it is a spawn. Honest limit: a verdict holds when it is read. init resolves over the payload, which holds no adopter content, so a marker written later is owed from the next doctor or env-probe read, and the gate’s fail-closed exit 2 on a missing bash keeps the gap loud until then.

    The fourth arm is read over the anchor’s registry. A member the anchor’s gates.list registers, resolving to a .sh declaration in the gates dir, owes bash through the SDK root’s kit, whose runner spawns it. A member resolving into a kit’s checks/ adds nothing, since the kit-root arms already read that file, and an unregistered .sh adds nothing, since no battery runs it. It shares the third arm’s honest limit: init reads the payload and passes no gates dir, and the adopter registers the gate later. A bash surface is whatever any of the four arms reads.

    The floor-holding root is narrowed past, named by the root the SDK occupies rather than by a literal. installer/profiles.list puts it in every profile, so admitting it would make the member unconditional, the floor the adopter constraints refuse (gate-sdk/SPEC.md §The adopter constraints, the floor is git). The narrowing binds the two kit-root arms; the fourth arm names the floor-holder because it fires on the anchor’s content, never on the selection. The SDK root’s own bash surfaces are the front-end stub no starter path needs and the contributor-side build script, which the contributor audience and the door surfaces account for; the adopter’s opt-in shell-gate skeleton is owed through the fourth arm once a copy is registered.

    No kit root ships a surface today, by the derivation: every shipped hook registration is the harness’s exec form, whose command names the gate binary (gate-sdk/SPEC.md §The harness-integration arm). The member is therefore owed through the two anchor arms alone, a marked fence and a registered shell gate, and the two kit-root arms stay so a kit that ships a surface later joins. A git hook is the gate binary (gate-sdk/SPEC.md §git-hook), and init spawns the binary it placed rather than the front-end (installer/SPEC.md §init), so neither puts a selection on the bash floor. The derivation opens files, so it walks the repository-path spelling of the kit roots, not the gate-sdk-parent one (gate-sdk/SPEC.md §lib/gate.sh).

  • cargo:1.71::contributor — a contributor-side floor, never a runtime one, declared on the element so the consumer-side predicate above enforces that reading. Two kinds of tree read it, so it has two tiers:
    • Where a .gate descriptor is live it is a commit-time floor, this repo’s case. gate_command puts the binary on the pre-commit path and is fail-closed on an absent one, so the battery will not run without a built crate (gate-sdk/SPEC.md §What the dispatch seam does not settle).
    • Everywhere else it is the contributor/build floor: a consumer tree receives a prebuilt binary and never a crate, so nothing there compiles at commit time.

    Both tiers rest on the highest MSRV in the crate’s resolved dependency graph, above the edition = "2021" floor of 1.56 since the crate took its first dependency (gate-sdk/SPEC.md §The settings cohort, and the crate’s first dependency). It is re-derived against the lock at any dependency change, and a move carries every surface stating it, including the rendered verdict below and any consumer page quoting this element as a format example, none machine-checked. A format example is the shape a floor move keeps missing: it reads as illustration, so it survives the grep a reader runs for the surface. It is also a check-crate-arms input, since clippy suppresses a lint whose suggested API postdates the declared floor, so raising the floor un-suppresses lints against untouched code. cargo stands for the toolchain rather than rustc because cargo build is what the contributor routine and the gates workflow invoke, and the two ship as one toolchain sharing a version banner: one member stands for a package family, the program the forcing construct invokes. The floor tracks what the crate and its graph require, never a given box’s rustc. Runtime is unaffected: git remains a ported gate’s sole runtime dependency, shelled out rather than embedded.

  • git:2.15 — git rev-parse --is-shallow-repository, which check-docs-cmd (native/src/gates/docs_cmd.rs) runs; the flag arrived in git 2.15 (its 2.15.0 release notes). The failure below it is silent: an older git prints the flag back as text with exit 0, so the gate reads a shallow clone as full and reports nothing, as measured on git 2.7.4, while 2.17.1 answers false.
  • curl:5.9::delegation-kit — -S (--show-error), in the -fsS that delegation-kit’s usage poller (native/src/hook/poll.rs) passes; curl’s own option documentation records it Added: 5.9, and every other flag the poller and the install lines use is 5.0 or older.
  • shellcheck:0.6::registered — -S warning, which check-shellcheck (native/src/gates/shellcheck.rs) and check-action-run-shell (native/src/gates/action_run_shell.rs) pass; ShellCheck’s changelog adds -S in 0.6.0, 0.5.0 refuses it with exit 3, and gate-sdk’s fixture suite is clean under 0.6.0.
  • jq:1.5::contributor — --argjson, --slurpfile and the regex builtins, which guard-kit/smoke/install.sh uses; jq’s NEWS adds them in 1.5, and 1.4 refuses --argjson with exit 2.

Every member carries a floor. A member the survey finds no versioned construct for renders any (installer/SPEC.md §Requirements), so the page never leaves a floor blank. Honest limit: a construct added later raises a floor silently unless its author re-runs the survey, because no gate reads a program’s flags against a version table. The survey is re-run at any change that spawns a program in a new way; a battery run on an old host does not replace it, since the git floor’s construct fails silently there.

No construct the shipped code runs forces GNU coreutils, so no member stands for that family. The floor predicate compares versions in-process, the binary reads civil dates in-process on unix (drift-kit/SPEC.md §Bundled KPIs) and computes relative paths lexically, and stat -c has no live site. awk and sort are off the roster: no shipped program runs awk, and the POSIX sort -u the shipped shell keeps rests on GATE_SDK_PROGRAM_FLOOR’s assumption (gate-sdk/SPEC.md §lib/gate.sh).

An implementation token is matched as a substring of the tool’s own version banner, a GNU coreutils tool printing (GNU coreutils) in its own, so the constraint is checked against the binary actually on PATH, never against a package name nothing can probe. The roster asserts what PATH resolves at probe time, so an installed GNU userland that PATH does not order first probes as below contract, correctly: that is what the gates will invoke.

The floor predicate. tool_floor_check <element> <banner> returns one verdict from a closed set: ok, absent (an empty banner), below <found> <floor>, wrong-impl <found>, uncomparable.

  • The comparison is in-process and spawns nothing: field-wise numeric over the dotted digit runs, the shorter token padded with zero fields, so 4.3 meets 4.3.0 and 4.10 is above 4.9.
  • <found> is the banner’s first dotted-version token for below, and its first word, the implementation’s own name, for wrong-impl.
  • uncomparable is the fail-closed arm, with one cause: a banner or token the predicate cannot compare (no dotted-version token, a field not an ASCII digit run, or one overflowing a 64-bit integer) is reported unverified, never as ok. That is the posture gate-sdk/SPEC.md §The gate model requires of a gate, applied to a probe that is not one.

The predicate has one holder, in the crate, which the installer’s doctor and this arm both call (installer/SPEC.md §The install boundary). It has no shell twin and no parity harness: under gate-sdk/SPEC.md §The non-gate arm’s rule a parity arm’s caller is its second holder, and one holder cannot be held equal to itself.

Every floor probe resolves its member to a path before spawning it, only where the platform has a system-directory homonym. Windows searches that directory before PATH, so a bare name reaches whatever the platform ships under it. The presence probe walks PATH and the spawn does not, so for a member the system directory ships a different program under, the two would disagree and the verdict would describe the homonym. Both probes therefore resolve the member outside the system directory first, rejecting every view the platform shows that directory through (gate-sdk/SPEC.md §check-graph).

  • A host offering the member nowhere else falls back to the bare name rather than refusing, because the floor roster’s own absent or wrong-impl is then the true reading.
  • That fall-back is the floor probe’s resolver’s own and never reads gate-sdk’s homonym roster, whose rows all refuse: a reporting resolver needs a reading of the host, not a refusal it cannot render. It keeps its own callers for the same reason, its value rendered in the banner below and in doctor’s, not only spawned.
  • Elsewhere the name passes through unaltered. A POSIX spawn already searches PATH alone, and resolving there would swap the spawned literal for an absolute path on every host the battery runs on, which is what a registry declaration is compared against.

The rendered verdict. Each toolchain bullet carries the probed banner, and for a constrained member its constraint and verdict: (floor 4.3, ok), (requires GNU — below contract), (floor 4.3 — unverified); an unconstrained member carries no parenthetical.

  • A member carrying an audience carries it here as <audience>-only: (floor 1.71, contributor-only, ok), (guard-kit-only, ok), (registered-only, ok). Every line naming such a member is marked the same way, the absent and below-contract lists included, since on those two lines below a floor that is yours and below a floor you are not on the hook for would otherwise read alike.
  • A **Below contract:** line joins the **Absent:** line, reading none when clean and otherwise naming each failing member through the verdict’s own fields. below and wrong-impl are distinguished because the remedies differ, upgrade against install another implementation, and uncomparable is listed as unverified, never folded into the clean state.
  • Both version probes read from /dev/null. -V prints a version banner for most tools but is an ordinary flag for some, GNU sort’s version-sort among them, so a tool rejecting --version would otherwise fall through to a -V that reads inherited stdin and hangs the probe.

The content seam (consumer-local, gitignored). Hand-authored gotchas, the “no dig/host; use getent/DoH” class a probe cannot know, live outside the markers in the same file and survive every re-probe. When the file is absent the probe seeds that scaffold once, then only rewrites the block. The per-session re-probe (Cadence, below) never seeds: it runs only against a file that exists, so seeding stays a first-run or on-demand action. The file is *.local.md-class, local-only and gitignored (the consumer’s .gitignore and its always-loaded housekeeping line carry it), so machine facts stay private.

Cadence — per-session auto-refresh. The session-context hook re-probes once per session, at its step-9 profile emit (§The session-context hook), so a session adapts to the box as it is now: the install step seeds the profile once and every session refreshes it. Change-detection keeps this cheap and the date honest: the probe rewrites the block only when the probed content differs from what is on disk, the Probed date line excluded from the comparison. There is no freshness gate: env truth is not cheaply machine-verifiable and the probe is already the derivation, so the per-session re-probe is the enforcement (the enforcement-first carve-out). The hook re-probes only when the profile file exists, so a never-seeded consumer pays no cost and seeds nothing unbidden.

The always-loaded meter

The meter measures the standing surface: the code-point count of the configured surface files (CONTEXT_KIT_SURFACES, default CLAUDE.md) plus the steady-state hook body. The count is queue-kit/SPEC.md §check-queue-entry-budget’s: each non-blank line trimmed, plus one per break between two of them, so a reflow moves nothing. The hook body is approximated by the configured hook-body command (CONTEXT_KIT_HOOK_CMD, whose default §Layout and configuration owns). The meter never runs the session-context hook itself: the hook emits this meter’s own line, so self-measurement would recurse and inflate.

The meter lives here, not in drift-kit’s collator, because the metric is context economics and the report is drift reporting: drift-kit’s kpi-always-loaded consumes this measurement for its row instead of re-embedding it.

It is the --emit-always-loaded arm-table member, and both halves are forced.

  • A table member, because it resolves consumer knobs and a hardcoded flag has no row to declare them on: CONTEXT_KIT_SURFACES, CONTEXT_KIT_HOOK_CMD, CONTEXT_KIT_BASELINE_FILE, CONTEXT_KIT_GROWTH_PATHS, CONTEXT_KIT_CEILING_FILE, CONTEXT_KIT_RATCHET_PATHS and CONTEXT_KIT_STATE_FILE. GATE_SDK_WORKFLOW_DIR and GATE_SDK_GATES_DIR are not declared: the static rows whose defaults derive from them declare them as inputs (§Layout and configuration), and resolving those rows resolves them (gate-sdk/SPEC.md §The knob file).
  • An Arm::Emit, because every mode returns 0 and the one non-zero path is exit 2, so that variant’s {0, 2} collapse discards nothing; --emit-usage-trend is admitted on the same ground.

The measurement is a library function the arm wraps, the kit’s split (§bin/footprint). It returns the figures: the surface, hook and combined totals; the baseline row’s total, surface count and commit where one resolves; and, where the caller hands in an iteration-start commit, that commit and the surfaces’ size there. The arm renders its modes over them, and kpi-always-loaded reads the same figures in process rather than parsing the rendered line back apart.

The modes arrive as operands, never composed into the flag: --emit always-loaded, then --growth, --update-baseline or --ceiling, the shape --hook and --wait-probe carry for their subcommand words. A flag per mode would be another row publishing the same knob roster, and the front-end’s grammar would copy a decision the arm table holds.

An operand outside that closed set is a refusal: usage on stderr, exit 2, --help included. The ground is harm, not argument shape. --update-baseline is a close-stage act, and a misspelled one falling through to the bare reading would print an ordinary meter line at exit 0 and write no baseline. The close would then commit an unrewritten baseline, and the next brevity pass would react to a delta measured from the wrong commit.

The hook body stays spawned, as the knob’s argv with no shell; calling queue-index in process is refused. The knob is a command seam: a consumer names their hook body, and the default happens to name queue-kit’s arm. An in-process shortcut would silently mismeasure every consumer whose hook body is another command. The index-test case driving this member with a cat <file> argv is such a consumer, and the seam’s witness. A command that cannot be spawned contributes hook 0, and a command’s stdout counts whatever its exit status.

  • Default invocation prints one line: the total, the per-part breakdown, the delta against the baseline when one exists, and, where an iteration-start commit exists, the surfaces’ delta since it. The line marks the baseline stale when the row’s surface count differs from the surfaces’ size at the iteration-start commit: a close skipped its re-stamp, so the baseline delta is cumulative. The mark covers the surface half only, since the hook body’s size at a past commit is not recoverable.
  • --ceiling rewrites the ceiling file (§The surface ratchet) to current sizes, creating it if absent; the baseline row stays. Checked write; exit 2 names the path.
  • --update-baseline rewrites the baseline file, and the ceiling file where one exists. It is a close-stage act because the brevity pass reacts to the delta, not the level: growth since the iteration started is the worklist. The write is checked and a failure names the path at exit 2, the refusal shape gate-sdk/SPEC.md §The non-gate arm rules for this class, since a confirmation of a rewrite that did not happen is worse than none.
  • --growth prints the brevity pass’s other worklist: a header with the count of files that grew and their net code points, then one row per file under CONTEXT_KIT_GROWTH_PATHS whose net growth is positive, largest first. A stale baseline adds a second header line naming both surface counts. It is a mode of its own because kpi-always-loaded reads the bare invocation as one line.
    • It measures from lifecycle-kit’s iteration-start commit (lifecycle-kit/SPEC.md §The state machine), through that section’s shared crate read on CONTEXT_KIT_STATE_FILE, so the worklist is this iteration’s growth whether or not the last close re-stamped.
    • With no iteration-start commit it measures from the baseline row’s commit, and one git cannot resolve prints a one-line notice and exits clean. Neither path adds a second baseline file.
  • Baseline file (CONTEXT_KIT_BASELINE_FILE, committed): a # contract: header pointing here, then one data line <total>cp <surface>cp <baseline-commit>. It is the consumer-side floor-holder for the footprint contract (§The consumer footprint).
    • A row whose figures carry no cp suffix is a line-unit row. The bare reading prints baseline in retired line unit — re-stamp with --update-baseline in place of a delta and exits 0. The discontinuity shows where the reader meets it, never as a phantom fall, and the stale mark stays silent until the re-stamp.
    • Trailing extra fields are tolerated and preserved-ignored, so a consumer’s file carrying a fourth (a settings-local count its drift report owns, say) reads unchanged.

bin/footprint

The footprint emitter publishes the kits’ measured context footprint, attributed per kit and split by when the cost lands: the adoption-cost evidence a consumer weighs before vendoring, the concrete form of the token-economics positioning. Where the meter reads one consumer’s live always-loaded total, this reads the tracked kit surfaces.

It is the publisher’s own arm, and no adopter-facing surface lists it. It prints the publisher’s page, front matter and regeneration command included, over a roster derived from the SPEC.md files the payload withholds (gate-sdk/SPEC.md §Consumer payload). In an adopter’s tree it gives no vendored kit a row, and gives one to any directory of theirs carrying a SPEC.md. The arm stays in the binary because §check-footprint-fresh and the value rollup call its library function in process. An adopter reads its own context cost off the meter. Knobs for the page head and the roster are refused: they would buy an adopter load-triggered figures the published page already prints, a kit’s templates shipping byte for byte, and an always-loaded share the meter already counts.

The measured set is derived, not maintained: every top-level directory carrying a SPEC.md. No knob names it and nothing tests for kit-hood, so a row states that directory’s context cost, never that it is a kit. A directory with no always-loaded block and no templates/ tree is reported with empty tiers rather than filtered out, since a suppression would be a second rule.

Each kit is measured in the always-loaded and load-triggered tiers:

  • Always-loaded — the block a kit generates between its own begin/end markers in the configured surface files (CONTEXT_KIT_SURFACES), the agent-file text the kit injects into every session’s context. This reuses the always-loaded meter’s surface set; most kits inject nothing and score zero here. A block’s trailing newlines fold to one before it is counted, and an empty block is skipped.
  • Load-triggered — the skill and template markdown the kit ships under its templates/ tree, pulled into context only when its trigger fires. Gate-test fixtures sit outside templates/, so they never enter the count.

Numbers ruling. Code-point counts are exact, each tier counted with the meter’s measure, and the cell reads <n>cp · ~<t>t. The token column is a labeled estimate: integer bytes/4, carried with a leading ~ and stated inline as model-tokenizer-dependent, never a false-precision figure, so a tier under four bytes reads ~0t. An empty tier renders as an em dash.

Attribution ruling: kit share only. A kit’s advertised cost is what the kit ships, and the page states what it excludes so the number is honest. Excluded: consumer bindings (the skill shims pointing at a vendored template), consumer config, the on-demand SPEC and README pages, the publisher’s own agent-file residue, and the session hook’s dynamic body (consumer state, not fixed kit text). The exclusion is also a determinism requirement: the freshness gate byte-compares the emission, so every measured surface must be a static tracked file, and a state-dependent live hook run could never be byte-gated.

Emission. The emitter is a non-gate arm of the gate binary, --emit-footprint (gate-sdk/SPEC.md §The non-gate arm), reached as bash gate-sdk/bin/run-gates.sh --emit footprint. It prints the committed footprint page (CONTEXT_KIT_FOOTPRINT_FILE) whole: front matter, method and exclusion prose, then the table with a totals row. It reads CONTEXT_KIT_SURFACES as one of context-kit’s static knobs (§Layout and configuration).

The emission is a library function the arm wraps, so §check-footprint-fresh calls it in process and the value rollup reads its per-kit figures as data rather than re-parsing the rendered page. That split is the kit’s shape: §The always-loaded meter takes it for the same reason.

The arm has no advisory bare mode: it prints the page and nothing else. It is advisory by construction, never joining gates.list, and §check-footprint-fresh is what blocks a stale page.

The brevity gate

check-brevity is section-agnostic: the governed sections are a knob, so no section name binds the gate. It scans one always-loaded file (CONTEXT_KIT_BREVITY_FILE) for a set of bulleted sections (CONTEXT_KIT_BREVITY_SECTIONS), where every top-level - item carries a code-point budget. It flags a bullet that is over budget and cites a deeper doc (carries a § pointer): over-long while admitting its detail has a home elsewhere.

  • Under-budget bullets pass, and so do over-budget bullets with no pointer, which may own their content.
  • <!-- brevity-exempt: <reason> --> on the bullet’s first line or the line above blesses a bullet whose every line is load-bearing.

The item. A bullet is a top-level - item extending to the next one or the section’s end, so an indented item belongs to its parent. Its size is the meter’s code-point count over that extent, to its last line carrying content, so a trailing blank adds nothing. A bold name is not required: a restating bullet costs as much without one, and a predicate reading only - ** items would leave most housekeeping-shaped sections ungated. The predicate is this member’s own argument to the shared section-walking primitive, which check-doctrine-registration calls with its own, so widening it moves no other gate’s walk.

The finding line names each over-budget bullet by its governed section, its line number and its lead, and reports its size in code points. The lead is the bold run where the bullet opens with one, else the opening characters of its lead line, cut on a character. With several sections governed, a finding naming the bullet alone could not locate it. The clean line names each section with the count of bullets read in it.

Section resolution fails closed. The knob and the heading are a coupling no other gate holds, and a gate whose target vanished is a broken machine, not a clean tree:

  • An element matching no heading in the governed file exits 2 naming that heading, even while its siblings resolve.
  • An empty set exits 2: a registered gate governing nothing is the vacuous pass the fail-closed contract refuses, and a consumer wanting no brevity check unregisters the gate. Unlike CONTEXT_KIT_RATCHET_PATHS, whose empty default means no load-triggered surface, an empty brevity set has no reading.
  • A section that resolves and holds no bullets is clean: resolution fails closed, not emptiness.
  • A repeated element is scanned once, so a duplicated heading in config cannot double a finding or the clean line’s count.

One budget for the set. CONTEXT_KIT_BREVITY_CAP is a scalar. A per-section budget has no reader while no governed section wants a different value, so it is not a field; a consumer that attests one reopens it.

The pointer default is any § rather than one doc name such as HANDBOOK §: a consumer’s handbook is one instance of a deeper doc, and the superset matches every such pointer. The gate ships a good/+bad/ fixture pair and registers in the consumer’s gates.list.

Two honest limits.

  • The unit is the code point. A bullet is measured the way the meter measures a surface, so joining or wrapping its lines moves nothing, and the apparatus still measures one quantity.
  • The pointer conjunct reads § by default, so a bullet pointing at a document by path alone passes on it. CONTEXT_KIT_BREVITY_POINTER_RE is the consumer’s lever; widening it to a path pattern also matches a bullet whose subject is a file, which is most of a housekeeping section, so the default is the conservative setting.

Prose outside any bullet, an agent file’s paragraph sections, is outside this gate’s grammar. Its length is bounded by canon-kit’s check-prose-bounds, where a consumer lists the agent file in that gate’s corpus (canon-kit/SPEC.md §check-prose-bounds).

The surface ratchet

check-surface-ratchet — no governed surface above its committed ceiling.

  • Governed: CONTEXT_KIT_SURFACES, plus tracked files matching CONTEXT_KIT_RATCHET_PATHS; size = the meter’s code-point count.
  • Ceilings: CONTEXT_KIT_CEILING_FILE — a # contract: header, then <n>cp <path> per governed file, sorted by path.
  • Red: a file above its row, or a governed file with no row (a new surface grows from nothing). The report names file, size and ceiling, and prints --emit always-loaded --ceiling, committed with the growth.
  • Clean: otherwise. A row for a deleted or ungoverned file is ignored, so narrowing never reds.
  • Exit 2: ceiling file absent, a row unparsable, a row without the cp suffix (a line-unit row, the message naming --emit always-loaded --ceiling as the re-stamp), a knob unresolved.
  • Writers: never the gate — --ceiling in the growing commit, --update-baseline at close where the file exists.
  • Why a ratchet: no budget to calibrate; growth shows in the commit that causes it. A re-stamp makes it deliberate, not justified; close judges.
  • Why these files, per file: the kit’s case is a trigger-loaded instruction surface, which a trigger loads whole; a SPEC is read by section, and close’s growth walk reads it. A consumer may govern any authored surface whose growth it wants shown in the growing commit: a public page whose reader tier must not regrow, or a SPEC that grows by accretion between brevity passes, whose growth the walk alone meets only at close. A generated copy is left out: its size is its source’s.
  • Why its own file: --update-baseline rewrites the baseline as one row, whose surface count is the meter’s staleness witness.
  • Why on-surface: init writes no ceiling file; --ceiling arms the gate.
  • Outside: the hook body — consumer state, not authored text.
  • Limit: pathspecs outside the descriptor’s static couples= are held by the battery, not the hook.

The close-stage brevity pass

templates/close-brevity.md is the recurring close-stage step a consumer splices into its close skill (the guard-kit close-triage.md pattern). The procedure:

  1. Run --emit always-loaded, then --emit always-loaded --growth.
  2. Walk the always-loaded delta and every file the growth list names, asking two distinct questions: staleness (is it still true?) and brevity (is each block worth its cost at every read?). No file is exempt: an on-demand doc pays at every open, and a SPEC a stage opens each iteration is always-loaded in effect, the always-loaded tier being only the one that pays most often.
  3. Resolve by rewording or deleting, never by annotating: outdated context goes to git history, not to a “formerly…” note.
  4. Finish with --emit always-loaded --update-baseline and commit the baseline and ceiling files (§The surface ratchet).

The lexical share of the narration judgment, a fixed set of formerly…-class markers in the manifest set, is a blocking gate (canon-kit/SPEC.md §check-manifest-temporal). This pass keeps the semantic residue, is this sentence about the past?, which no marker set can decide.

The consumer footprint

A consumer project pursues its own objectives, so the tooling must stay near-invisible in its context budget. This section owns the kits’ consumer-resident footprint: what the kits ask a consumer’s always-loaded surface to carry. It is prose contract only, with no state, event, knob or gate of its own.

  • Producer: the kit author at kit-landing time. The kit-landing checklist gains no step; the review seam is the roster row below.
  • Readers: a consumer weighing adoption cost before vendoring, and a close-stage brevity pass judging whether a new resident line is a kit ask or the consumer’s own content.

The budget rule. A kit’s resident ask is at most one pointer line per obligation it owns whose bound actor fires no trigger that loads it — the load-trigger-residency and always-loaded-shape doctrine rules applied to kit shipping. The unit is the obligation, not the kit, and that is the rule rather than an allowance carved for a kit that outgrew it: a pair of such obligations merged onto one line states neither. What the rule forbids is unchanged — a second line for a rule some trigger already loads, and a line spent on mechanism the owning SPEC holds.

A block-sized ask, and the property that sanctions one. A resident ask may exceed a line when it is generated and held in lockstep by a gate. It then costs the consumer no authoring and cannot drift from the kit that emits it, the two costs the line budget bounds. Two asks qualify:

A hand-written block qualifies under neither, and is the case the line budget is for.

Resident once, never once per template. An obligation whose bound actor is any session is carried here and not restated in the kit templates those sessions load. delegation-kit/SPEC.md §Operative residency licenses one restatement where no bound actor’s trigger reaches the owning doc. A per-template copy multiplies the drift surface by the template count and reaches no reader this tier misses: no kit-side surface is loaded by every session shape, since each stage skill is its own binding shim and a lead loads no stage template at all. drift-kit/SPEC.md §The knowledge-friction loop refused a per-stage capture prompt on this ground, which is about the multiplication rather than that channel, so it binds every obligation this roster carries.

The corollary is what a template may still say. A template states such an obligation where its reader’s discharge differs from the resident line’s: instruction that surface authors for itself, which takes no sanction and is no restatement. It never carries the obligation’s channel or disposal-time half alone, since to a reader who meets no other statement of it, the deferred half reads as the discharge.

The roster, by citation. Each kit’s resident ask is named by citing the section that owns it, never restated, so the roster cannot drift from what the kit ships. A kit adding a resident ask adds its row, the review seam:

  • delegation-kit — the pre-authorization sentence plus skill pointer: delegation-kit/SPEC.md §One template, a resident pointer.
  • doctrine-kit — the doctrine link plus the digest block: doctrine-kit/SPEC.md.
  • drift-kit — the knowledge-friction capture bullet: drift-kit/SPEC.md §The knowledge-friction loop, which already states the one-bullet cost and its earn-back condition.
  • lifecycle-kit — the generated registration block (lifecycle-kit/SPEC.md §check-lifecycle-registration), plus one line per any-session obligation whose owning section no session’s trigger loads: gap capture and the recurrence stamp (lifecycle-kit/SPEC.md §The committed gap inbox) and survey capture (§The survey record). Each section states its line’s cost and earn-back condition.
  • every other kit — none: their hooks, skills, gates, and SPECs are load- or event-triggered, so they cost nothing until opened. The discriminator this row turns on, and the one the brevity pass reads: a resident line that emphasizes an already-enforced mechanism is the consumer’s own content, not a kit ask — only an obligation with no other carrier is.

The floor-holder ruling. The meter and its committed baseline (§The always-loaded meter) are the consumer’s floor-holder: the consumer install (§Layout and configuration) seeds always-loaded-baseline.txt, so resident growth is a visible delta at every close-stage brevity pass. The hold is advisory by design. A total-size gate cannot attribute growth, since the consumer’s own content shares the file and is theirs to grow. It would be a noisy check breeding exemptions, the high-false-positive case the enforcement-first rule keeps as stated manual duty rather than a gate. The mechanical holds stay:

  • check-brevity bounds the bulleted sections its knob designates (§The brevity gate). A doctrine digest is no candidate for that knob: it is generated one line per rule and held byte for byte by check-doctrine-registration, so a finding there is unreachable.
  • The meter delta feeds kpi-always-loaded.

The measured counterpart to this budget doctrine is the published footprint page (§bin/footprint): this section owns the budget rule, the page owns the measured per-kit numbers, and the two never restate each other.

The memory-off doctrine

Harness memory, the store the harness offers to persist facts across sessions, is an always-loaded surface the meter cannot read and no gate scans: standing per-session context accreting outside the tier contract, ungoverned by construction. The methodology’s existing routes replace it: durable facts to their doc owner (the knowledge-friction loop), iteration-scoped attention to the lesson channels, private context to the operator’s local brief. So the kit disables harness memory and enforces it off rather than governing its content.

The banned class is harness-side silent accumulation, not every local file: an explicit, derived, operator-curated local file is config, not memory. ENV.local.md (§bin/env-probe) sits with every *.local.md on the config side of that line. Its probed half is a re-runnable derivation and its gotchas half is hand-curated, both gitignored surfaces the operator chose to keep, never a store the harness wrote to behind the tier contract.

Blast radius. The gates hold the tree whatever a session’s memory holds, so a quietly re-accumulated memory degrades one session’s judgment, never the committed baselines. The enforcement is therefore a lightweight gate pair, not machinery, split on the tree-versus-environment seam:

  • what a commit can carry, the tracked settings file, is pinned hermetically and CI-real (§check-settings-pins);
  • what only the operator’s machine holds, the memory dir and the untracked local settings, is a local-environment scan, CI-neutral (§check-memory-off).

check-settings-pins

checks/check-settings-pins.gate (hermetic, precommit, dispatched to the gate binary — gate-sdk/SPEC.md §The settings cohort, and the crate’s first dependency) is the identity.conf pattern pointed at harness config. Every pin in CONTEXT_KIT_SETTINGS_PINS holds against the tracked settings file CONTEXT_KIT_SETTINGS_FILE (defaults: §Layout and configuration). Grammar: one <path> = <expected JSON> per line, # comments and blanks ignored. Any settings key is pinnable; the memory-off doctrine’s pins hold the auto-memory-disabling keys.

The left-hand side is a path expression, not a jq filter, and the grammar is complete:

path   := '.' | step+
step   := '.' ident | '.' '"' string '"' | '[' '"' string '"' ']' | '[' int ']'
ident  := [A-Za-z_][A-Za-z0-9_]*
int    := '-'? [0-9]+

A path opens with .. Everything else (pipes, filters, ? error suppression, slices, iteration, functions, arithmetic) is refused at exit 2 naming the offending pin, the knob it came from, and the construct. The leading . is load-bearing: jq reads a leading ["k"] as an array literal and returns ["k"], so admitting one would answer a question jq was never asked. The subset narrows a consumer config surface openly and refuses loudly outside it, unlike the silent mis-scan the ERE cohort’s foreclosure forbids, which sizes an implementation to one consumer’s usage while the documented grammar stays wider. The knob’s documented job is naming a settings key, which a path expression expresses in full, so a filter crate (jaq) is declined, and that one exists is no ground to reopen it.

The right-hand side is a JSON value, and the comparison is structural: no jq -c rendering is the contract. A byte rendering differs across jq versions (1.6 renders {"x":1e3} as 1000, 1.7 as 1e3), so it cannot be a parity target, and structural comparison has no such dependency; an expected side with non-canonical spacing matches. One rule the object model does not give free: numbers compare by their f64 value wherever they occur, every other shape by the parsed value’s own equality. The parser’s equality separates 1 from 1.0 where jq calls them one value, at any depth, so without the rule a nested pin would report a mismatch.

Two shell semantics are preserved rather than improved, since a port proves parity and repairs no rule:

  • A path evaluating to null is the absent branch, whether the key is absent or explicitly null. A session wanting the distinction files an entry against this section.
  • Indexing follows jq’s own type rules: a field step on null yields null; a field step on a string, number, boolean or array, and an index step on an object, are errors the gate classifies as a malformed pin.

Dispositions:

  • Pass: a pin whose path resolves to the expected value.
  • Exit 1: a path present with a different value, the legible violation, each finding reading path, expected and actual.
  • Exit 2: an unreadable or non-JSON settings file, a malformed pin line, a pin outside the path grammar, or a pin naming a key absent from the settings file. An absent key is a desynced manifest, the pins and the settings being one repo’s tracked config edited together, not the legible drift a red is for.
  • Clean skip: no pins file, the opt-in-off state.

It ships a good/+bad/ fixture pair, registers in the consumer’s gates.list, and the consumer smoke drives it end to end against an installed settings file (§Testing).

What a pin is worth depends on which tiers can outrank the file it pins, and the gate reads one. The harness resolves settings across five tiers, in order: managed policy, command line, the untracked local settings file, the tracked settings file this gate reads, the operator’s user-level file. Objects deep-merge; the permissions arrays, MCP servers, HTTP hook URLs and fallbackModel chains merge rather than override. Two consequences the verdict does not carry:

  • A pin beats the operator’s user-level file, which makes a tracked pin more than a suggestion the machine can quietly ignore.
  • A pin is beaten by the three tiers above it. check-memory-off covers one, the local settings overlay. Managed policy and command-line flags stay outside any gate’s reach, unclosably: neither leaves an artifact in the tree, and the command line does not exist until the session starts.

check-settings-paths

checks/check-settings-paths.gate (hermetic, precommit, dispatched to the gate binary alongside its sibling — gate-sdk/SPEC.md §The settings cohort, and the crate’s first dependency) holds the second invariant over the same tracked file: every path the file tells the harness to run resolves in the working tree. That is each permissions.allow[] entry whose command token is a literal repo-relative .sh path, and each type: command hook under hooks, whatever its event, whose command token is a literal repo-relative path. The knob is §check-settings-pins’, reused rather than introduced.

The class it defends recurs with every gate port: replacing a kit’s checks/<gate>.sh with a <gate>.gate descriptor strands every allow entry naming the old path. A dead hook costs more than a dead grant. The harness treats any status but 2 as a non-blocking error, so a hook whose script is gone lets every call through while the rule it carried stops with no red anywhere; deleting a script before swapping its registration opens exactly that window. A dead grant breaks nothing, since no command can reach it, but every reader of the permission roster must re-verify which lines still mean anything.

The extraction predicate, scoped against the shapes a permission array actually carries:

  • A candidate is taken only from a Bash(<command>) entry. Leading env NAME=VALUE assignments and a bash/sh interpreter word are skipped, and the first remaining token is the candidate.
  • A candidate not ending in .sh is out of scope. A bare command names no path, and a non-.sh path entry (a log truncation, a scratch removal) names a runtime-created gitignored path whose absence is its ordinary state, so existence is the wrong predicate for it.
  • A candidate containing * is a pattern, polymorphic over files that need not exist today, and is skipped. The rule reads the command token, not the entry: a grant taking arguments is commonly a bare entry beside a *-suffixed twin, and the twin’s path is as literal and as strandable as the bare form’s, so it stays in scope.

A hook’s candidate is scoped differently, on the same walk:

  • Its tokens are command split on ASCII whitespace, or in the exec form command followed by each args element verbatim, with the same env and interpreter skip.
  • The candidate is taken when it carries a /, whatever its extension. The runtime-created ground that scopes a grant to .sh does not transfer: a hook names what the harness executes, never a path it creates.
  • A leading project-root placeholder (${CLAUDE_PROJECT_DIR} or $CLAUDE_PROJECT_DIR, quoted or bare, then /) is stripped, and the rest resolves repo-relative, the root the harness substitutes.
  • A suffix-less candidate resolves when its path is a file, or, where its file name carries no extension, when the path with the host’s executable suffix is one. One committed registration naming the gate binary therefore resolves on a Windows and a POSIX clone alike.
  • A candidate rooted outside the tree is skipped and counted: one carrying any other $, such as a plugin’s root, and an absolute one, by the path-dialect contract’s root test (gate-sdk/SPEC.md §The path-dialect contract). Whether a file stands there is a fact of the machine and not of the commit. The clean line’s skipped count names both.
  • An unresolved binary placeholder reds. A candidate still carrying @GATE_SDK_NATIVE_BIN@ was merged from a kit template without its placeholder step, starts no program, and leaves its guard off (guard-kit/SPEC.md §The shell guard). The finding reads — unresolved placeholder: @GATE_SDK_NATIVE_BIN@ and its help: line names the merge step and the command printing the value.
  • A stated limit, not a scope: a hook running its script through an interpreter other than bash/sh (node, pwsh -File) puts the script in argument position, where the walk does not look.

A hook naming a --hook door names a member too, and both resolve. The door is a command token the registration grammar reads: a front end’s file name, the binary’s own, or its placeholder (gate-sdk/SPEC.md §The harness-integration arm). Where a hook’s command token is one and the next token is --hook, the token after it must name a member the binary carries. A missing operand is a finding too, and an unresolved placeholder’s member is still read. The roster is read in process from the binary’s own table, so the gate and the dispatch it predicts cannot disagree. An unknown member exits 2 at run time and blocks every call the hook guards, so the gate catches it at the commit instead of in a wedged session. The finding reads — no such hook member: <name>, and its help: line prints the roster. The token walk and the registration parse are the binary’s one registration grammar, beside the member table (native/src/hook/mod.rs). The enforcement map and check-plugin-parity’s rendering read registrations through the same functions (gate-sdk/SPEC.md §enforcement-map, plugin/SPEC.md §The guards), so no reader parses a registration a second way.

The hook half widens this gate rather than minting a second one: the settings file, the fail-closed posture, the fixture mode and the command-token walk are already this gate’s, and a second name would buy nothing.

The predicate has a second reader: guard-kit’s compare-settings-allow applies it to the local overlay as an advisory (guard-kit/SPEC.md §compare-settings-allow). It calls this gate’s holder rather than restating it, so a change to the scoping above changes that report too. The gate’s subject stays the committed file alone.

Splitting the grant into tokens must not expand it. Globbing a pattern grant against the tree and asserting a first match would green the whole pattern class instead of skipping it, while silently inflating the checked count. The split is on ASCII whitespace, which expands nothing, and the fixture pair’s checked count pins the property.

Dispositions:

  • Exit 1 lists each violating grant verbatim, and each violating hook as hooks.<Event>[<i>].hooks[<j>]: <command>, beside the path that did not resolve, so the reader can repoint or drop the entry without re-deriving which token was read.
  • Clean reports the checked counts: grants, hooks, and hooks skipped for a placeholder. They distinguish a predicate that scoped to its subject from one that vacuously matched nothing.
  • Exit 2 on a settings file that is unreadable or not JSON. The file is this gate’s sole subject rather than an opt-in manifest, so there is no absent-surface skip to grant.
  • --fixture <dir> reads <dir>/settings.json and resolves candidates against <dir>, the hermetic mode the good/+bad/ fixture pair drives.

The pair pins the scoping, not merely the verdict:

  • good/ carries every skipped grant shape (pattern tokens, bare non-path commands, non-.sh paths) beside resolving literals in all three extraction shapes (bare, env-prefixed, trailing-flag). Its hooks, under two events, are a bare script, an env-prefixed one, a placeholder-prefixed one, an exec-form args one, a --hook registration naming a member the binary carries, a $-rooted token and a non-command hook type. Its expectation pins both checked counts: an expanded pattern resolves by construction, so the pattern-expansion defect passes an exit-code-only fixture and shows only in the count.
  • bad/ carries a dead grant path in the bare, *-twin and env-prefixed shapes, a dead hook script and a --hook operand the binary does not carry, each beside a resolving one. A broken extraction arm then shows as a missing finding rather than a still-red exit.

Its # graph: manifest names the settings file as its subject and the check script globs as a reverse trigger, not content, a distinction gate-sdk/SPEC.md’s port criterion 4 turns on. The trigger is a partial route by construction: the pre-commit hook reads staged ACMR paths, so a deleted .sh never matches it. It catches the ordinary edit that strands a grant; the full battery, run whole-tree with no trigger filter, catches the deleting cohort.

The gate and its sibling read the settings file through the crate’s JSON reader (gate-sdk/SPEC.md §The settings cohort, and the crate’s first dependency), so neither needs jq.

The gate is not the prune. It writes nothing, yet reds the moment it registers on a tree carrying stranded entries, and the settings file is operator-owned configuration a session edits only on the operator’s behalf (guard-kit/SPEC.md §compare-settings-allow). The landing order is therefore fixed: the prune lands, then the gate registers.

check-memory-off

checks/check-memory-off.gate (local-environment class, binary-dispatched, the check-identity precedent) scans the operator’s machine, not the tree. Its # graph: manifest couples the pins file it reads and triggers on *, because the surfaces it guards (the memory dir and the untracked local settings) never stage. Two red conditions:

  • the harness’s per-project memory dir holds content — any regular file that is not the dir-preserving .gitkeep;
  • the untracked local settings file (settings.local.json beside the settings file) sets a pinned key to a value other than its pin — the override the hermetic gate cannot see, since it reads only the tracked file.

CONTEXT_KIT_MEMORY_DIRS (a space-separated glob list) names the dirs to scan; its default derives the current project’s dir from the harness layout (§Layout and configuration). CI-neutral: where the surface is absent the gate is clean, and the clean line states the fail-open caveat, that an absent dir proves nothing about another clone.

It has one arm, and the knobs are it. It takes no --fixture mode, since a knob already redirects every path it reads: the scanned dirs, the pins manifest, and the local settings file, derived from CONTEXT_KIT_SETTINGS_FILE by swapping .json for .local.json. A fixture arm would be a second code path that never drives the derivation under test. The pair and check-memory-off.test.sh reach the gate through those three knobs, which makes them a parity oracle for the live arm rather than for a fixture-only one.

--emit memory-dirs prints the scan set, one directory per line, from the same resolution the gate scans, so the session-context hook’s step 5 folds nothing of its own. check-memory-off.test.sh holds the two to one dir under the derived default.

Comparison is structural and a null actual is a skip, the two points where this gate parts from check-settings-pins over one manifest:

  • The pins file declares expected JSON, so 1 and 1.0 are one value here as there. A right-hand side that is not JSON cannot be compared and is skipped, this member’s one disposition for a pin it cannot read; the sibling fail-closes on that same line, so the condition is graded, not lost.
  • A path evaluating to null is this gate’s ordinary clean case: the local file sets no override for that key. The sibling refuses a null as an absent pin, right for the tracked file it reads and a regression here, where it would red every clone whose local settings merely omit a pinned key.

Fail-closed (exit 2) when it cannot read what is present to check:

  • a local settings file that is unreadable or not valid JSON;
  • a memory dir it cannot walk;
  • no home it can read, when the default derivation is in play and CLAUDE_CONFIG_DIR is unset.

check-footprint-fresh

It is the publisher’s own gate and does not ship, on §bin/footprint’s ground: its subject is that arm’s page. The descriptor and its fixture pair sit in the publisher’s gates dir; the rule stays in the crate.

check-footprint-fresh (hermetic, precommit) byte-compares the committed footprint page (CONTEXT_KIT_FOOTPRINT_FILE) against the footprint emitter, the check-docs-mirror-fresh posture: a generated, freshness-gated projection is Derivation-first’s sanctioned copy, so a stale page reddens the battery until the emitter is re-run. Its # graph: manifest couples the measured surfaces (the configured agent file and each kit’s templates/ tree) and the */SPEC.md glob the roster derives from, so an edit to what the page counts, or a kit arriving or leaving, re-fires the gate.

It is a registry member of the gate binary, and its emitter is a function call rather than a spawn. It calls the emitter’s emit() in process, leaving no bash hop (gate-sdk/SPEC.md §The first cohort, and the rule that selects the next). It declares CONTEXT_KIT_SURFACES, the static knob the emitter reads.

  • Bare, it runs the live emitter; good/ exercises that branch over a staged one-kit corpus.
  • Two arguments (check-footprint-fresh <projection> <emit>) compare two pre-baked files, the hermetic mode the bad/ case drives.
  • Exit 1 is the stale byte-compare; exit 2 a missing projection or emit source.

The page’s generated figures pass the prose gates on content, not a named valve: they sit in table cells the count gate does not read as prose, and the method prose names no bare collection total.

Layout and configuration

context-kit/
  checks/check-brevity.gate      # hermetic, binary-dispatched: the governed sections' over-budget pointer bullets
  checks/check-surface-ratchet.gate # hermetic, binary-dispatched: every governed surface at or below its committed ceiling
  checks/check-settings-pins.gate  # hermetic, binary-dispatched: pins hold against the settings file
  checks/check-settings-paths.gate # hermetic, binary-dispatched: literal .sh grants and hook paths resolve, --hook members exist
  checks/check-memory-off.gate   # local-environment, binary-dispatched: memory dir + local overrides
  gate-tests/check-brevity/{good,bad}/
  gate-tests/check-surface-ratchet/{good,bad}/
  gate-tests/check-settings-pins/{good,bad}/
  gate-tests/check-settings-paths/{good,bad}/
  gate-tests/check-memory-off/{good,bad}/
  gate-tests/check-brevity.test.sh      # the section-set resolution axes the pair cannot hold
  gate-tests/check-surface-ratchet.test.sh # the refusal axis the pair cannot hold
  gate-tests/check-memory-off.test.sh   # the local-override axis the pair cannot hold
  gate-tests/check-settings-pins.test.sh # the refusal axis the pair cannot hold
  index-tests/                   # fixture corpus + expected outputs
  templates/settings-sessionstart.json
  templates/nudge-delegation.txt # exemplar nudge
  templates/context-config.knobs # comment-only consumer knob file: a pointer to the roster below
  templates/close-brevity.md
  smoke/install.sh
  smoke/violation.sh

The kit carries no lib/ directory. Its knob defaults and config refusals live in its static defaults table and validator (below). The probe roster and the floor predicate live in the crate (§bin/env-probe), as do the built-in pub-index extractors behind the consumer extractor seam (§Index-first reading).

The install seeds two files. After wiring the hook it runs run-gates.sh --emit always-loaded --update-baseline once to write always-loaded-baseline.txt, the committed baseline the footprint contract holds (§The consumer footprint), and smoke/install.sh asserts the baseline lands. It also runs run-gates.sh --emit env-probe to write the first ENV.local.md block (§bin/env-probe). That profile is operator-local and gitignored, so no smoke asserts it; the stated install step is its enforcement.

Config is a knob file: copy templates/context-config.knobs into the gates dir as context-config.knobs, or point CONTEXT_KIT_KNOB_FILE elsewhere, and set any knob below; defaults fill what the file leaves unset. context-kit’s knobs are static, resolved in process from the binary’s defaults table and the consumer’s knob file, and bash gate-sdk/bin/run-gates.sh --emit knob-roster prints each with its shape and rendered default. A gitignored context-config.local.knobs in the gates dir holds a private value a tracked file cannot carry. The grammar, that overlay, the environment-over-file precedence for a scalar and the knob-file refusals are gate-sdk/SPEC.md §The knob file’s.

The kit’s table validator refuses a malformed context config at exit 2 with every finding, since a broken config must gate nothing:

  • an empty CONTEXT_KIT_SETTINGS_FILE, CONTEXT_KIT_SETTINGS_PINS or CONTEXT_KIT_BREVITY_FILE;
  • a CONTEXT_KIT_BREVITY_CAP that is not a positive integer;
  • the set-but-missing settings file below.

A derived default below names the knob it reads as ${NAME}, or as ${NAME:-<default>} so the roster’s rendered literal reads as agreement. Knobs:

  • CONTEXT_KIT_SURFACES — array of always-loaded files; default ("CLAUDE.md"). The measured surface is agent-file-name-agnostic: a consumer whose harness reads AGENTS.md, or any other always-loaded agent file, sets this to it, and the meter, the footprint and check-brevity all follow, no kit mechanism resolving the agent file by literal. The AGENTS.md adapter smoke (§Testing) exercises exactly that: an AGENTS.md scratch consumer whose battery is green and whose meter and footprint measure AGENTS.md.
  • CONTEXT_KIT_FOOTPRINT_FILE — the committed footprint page the emitter prints and check-footprint-fresh compares against it; default docs/footprint.md. An explicit positional still wins, which is the two-argument hermetic mode.
  • CONTEXT_KIT_PUB_LANGS — array naming the pub-index extractors to enable; default empty, which means derive it, not no languages: the knob’s one reader expands it to the arm’s built-in extractor roster (the CONTEXT_KIT_MEMORY_DIRS shape below). That roster lives in the crate, so a literal default would be a maintained list and a second producer of it; the table states the empty default and stops. An explicitly empty array is therefore the same input as leaving it unset.
  • CONTEXT_KIT_PRUNE_DIRS — array of leaf basenames both index walkers exclude below each target (§Index-first reading); default .git, node_modules, target, dist, build, worktrees, the last the harness’s worktree leaf. Deliberately not gate-sdk’s set, and not derived from it.
  • CONTEXT_KIT_PUB_LANG_DIR — the consumer extractor dir searched before the arm’s built-in roster, where a same-basename file shadows the shipped grammar; default ${GATE_SDK_GATES_DIR}/pub-lang.
  • CONTEXT_KIT_HOOK_CMD — the command whose output code-point count approximates the steady-state hook body (§The always-loaded meter).
    • A command knob: an indexed argv, one CONTEXT_KIT_HOOK_CMD[] = word line per element, spawned directly with no shell, and taking no environment override. A command needing shell syntax is written as a bash, -c and command-string argv.
    • Its default is a derived row: queue-kit’s queue-index arm through the battery front-end, ("bash" "<candidate>" "--emit" "queue-index" "--collapse-deferred"), for the first candidate that exists as a file among ${GATE_SDK_GATES_DIR}/run-gates.sh and ${GATE_SDK_ROOT}/bin/run-gates.sh, the gate-sdk root locator spelled relative to the working directory. That is consumer-first, then the sibling gate-sdk’s front-end, else empty (surfaces only).
    • The empty answer is what the meter’s hook 0 branch reads and what a consumer vendoring context-kit without a battery front-end depends on. A file line CONTEXT_KIT_HOOK_CMD = replaces the default whole with that empty value, a deliberate no-hook override.
    • --emit knob-roster renders the first candidate rather than probing a path built from a placeholder (gate-sdk/SPEC.md §The knob file).
  • CONTEXT_KIT_DRIFT_REPORT — the --emit arm name of the consumer’s drift report, not a path: the hook runs --emit <name> --trend for the brief’s drift line; default empty, the line omitted. A path value degrades to no drift line, the degrade an absent report always had.
  • CONTEXT_KIT_STAGE_RULES — the command that emits stage→craft-rule pointers, not a path: the session-context hook runs it with the current stage appended for the brief’s craft-rule block (§The session-context hook, the configured-command rule). Default empty, the block omitted; --emit stage-rules, doctrine-kit’s arm, is one value it takes (doctrine-kit/SPEC.md §stage-rules). A bare executable script path works as a command of one word; a non-executable script needs its interpreter written in the value, since the hook supplies none.
  • CONTEXT_KIT_BRIEF_COMPONENT_MARK — the directory name that makes a top-level directory a component for the brief’s dirty-surface step and its footer’s public-surface line; default src. Empty turns the step off and drops the line.
  • CONTEXT_KIT_BRIEF_NUDGES — keyed by stage, each value a tracked text file the brief prints verbatim on that stage; default empty, no nudge. templates/nudge-delegation.txt is the exemplar a consumer copies and keys to its stages.
  • CONTEXT_KIT_BRIEF_FOOTER_FILE — a tracked text file printed verbatim as the brief’s index footer; default empty, the built-in footer.
  • CONTEXT_KIT_BRIEF_COMMANDS — array of a consumer’s own brief lines, each element one command run after the drift line; default empty.
  • CONTEXT_KIT_STATE_FILE — the lifecycle evidence file; default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/WORKFLOW-STATE.txt. Its last data line carries the stage cursor the hook routes on (§The session-context hook). Its first stamp names the iteration-start commit the meter’s --growth and staleness read (§The always-loaded meter). It is context-kit’s own knob, never an import of lifecycle-kit’s, so a consumer without lifecycle-kit resolves an absent file and keeps the baseline-only reading.
  • CONTEXT_KIT_ENV_PROFILE_FILE — the consumer-local env profile the env-probe arm writes and the session-context hook’s step 9 emits (§bin/env-probe); default ENV.local.md.
  • CONTEXT_KIT_SESSION_ROLE_FILE — the session-role marker /lead writes and the session-context hook’s identity match reads (§The session-context hook); default ${GATE_SDK_TMP_DIR:-.tmp}/session-role, gitignored scratch.
  • CONTEXT_KIT_BASELINE_FILE — default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/always-loaded-baseline.txt.
  • CONTEXT_KIT_GROWTH_PATHS — array of git pathspecs the meter’s --growth arm measures; default ("*.md"). A consumer excludes generated mirrors and fixture copies here, since a copy’s growth is its source’s.
  • CONTEXT_KIT_CEILING_FILE — the ratchet’s committed ceilings (§The surface ratchet); default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/surface-ceiling.txt. A file of its own, not a baseline row, because --update-baseline rewrites that file as one row and would erase a ceiling at every close.
  • CONTEXT_KIT_RATCHET_PATHS — array of git pathspecs naming the load-triggered surfaces the ratchet governs beside CONTEXT_KIT_SURFACES; default empty, the always-loaded files alone. Empty here means no load-triggered surface, not derive it: which files a harness loads whole is the adopter’s layout, and a default spelling the kits’ template paths would red an adopter’s re-vendor commit for growth the kit shipped. The elements ride one git ls-files -- invocation, so an :(exclude) element filters every positive element wherever it sits in the array.
  • CONTEXT_KIT_BREVITY_FILE — default CLAUDE.md.
  • CONTEXT_KIT_BREVITY_SECTIONS — array of headings of the budgeted bullet sections; default ("## Shared conventions"), a one-element set. The scalar it replaces, CONTEXT_KIT_BREVITY_SECTION, is a retired name: a knob-file line naming it, or the name exported in the environment, is refused at exit 2 naming the replacement (gate-sdk/SPEC.md §The knob file). Ignored silently, it would leave a file carrying ## Shared conventions governed on the wrong section at exit 0.
  • CONTEXT_KIT_BREVITY_CAP — code points per bullet, a positive integer; default 330, a bound every pointer-carrying bullet a four-line budget admitted on a real resident file meets. The retired CONTEXT_KIT_BREVITY_BUDGET, lines per bullet, is refused at exit 2 naming this knob.
  • CONTEXT_KIT_BREVITY_POINTER_RE — the “cites a deeper doc” pattern; default §.
  • CONTEXT_KIT_SETTINGS_FILE — the tracked harness settings file check-settings-pins and check-settings-paths each verify, on different invariants, and whose .local.json sibling check-memory-off scans; default .claude/settings.json.
    • An explicit value naming no file is refused at exit 2 by the kit’s table validator, which reads the value’s origin beside it. A value from the environment, the local overlay or the tracked knob file is set; the default with no file at its path is not-adopted and degrades at each reader. A reader sees one path string and cannot tell the two apart, so the refusal belongs where the origin is visible.
    • Emptiness is not the signal, since the validator rejects an empty value as malformed. That is why this knob’s not-adopted default is a path that may be absent, where DRIFT_KIT_KPIS_FILE resolves empty at its not-adopted default (drift-kit/SPEC.md §Layout and configuration).
  • CONTEXT_KIT_SETTINGS_PINS — the pins manifest; default ${GATE_SDK_GATES_DIR}/settings-pins.conf.
  • CONTEXT_KIT_MEMORY_DIRS — space-separated glob list of harness memory dirs check-memory-off scans; default empty, which means derive it, not no dir: the current project’s dir, <config home>/projects/<slug>/memory.
    • The config home is CLAUDE_CONFIG_DIR or ~/.claude. <slug> is the harness’s own encoding of the repository root: every UTF-16 unit outside [A-Za-z0-9] folded to -, cut past 200 units with a hash suffix (lifecycle-kit/SPEC.md §bin/session-id.sh owns the encoder).
    • On Windows the root is folded in git’s drive-lettered spelling, D:/a/r becoming D--a-r under pwsh and Git Bash alike, and ~ is USERPROFILE, read even where HOME is set; elsewhere ~ is HOME. The witness for each fact is an unauthenticated harness run, which creates its project dir before refusing.
    • The derivation belongs to the module reading the knob (native/src/gates/memory_off.rs), shared by the gate and --emit memory-dirs. A table row cannot name the home as an input, and deriving lazily at that reader keeps a git subprocess off every unrelated knob resolution. It is a knob because the layout moves: design against the live layout, keep it config.

Every name the session-context member reads is a table row, a compiled member refusing a name its table does not declare. CONTEXT_KIT_MEMORY_DIRS reaches step 5 through --emit memory-dirs, which resolves it for itself.

Testing

The expected-output runner. The three index arms and the meter are advisory and speak plain text, so the gate contracts do not fit. The kit ships an expected-output runner instead, the --run-index-tests arm (gate-sdk/SPEC.md §The non-gate arm):

  • index-tests/ holds a small fixture corpus beside expected outputs. The corpus is Markdown with nested headings, fences and link-bearing first sentences; Rust with the pub-item kinds; a baseline file; and TypeScript with every kind the ts grammar claims, const enum and export default included, beside re-export and non-export lines it must skip.
  • The arm drives each case through the --emit front-end over that corpus and asserts exact output, failing on any diff.
  • It spawns the arms from the host checkout, so it pins every knob an arm reads at the corpus, the meter’s iteration state file included: an unpinned knob resolves against the host’s live state and leaks it into the golden.
  • The goldens are the parity oracle, and unusually strong ones: the shell implementations the arms replaced produced them, so holding them byte for byte is a cross-substrate comparison over a committed corpus. A golden edited to fit an implementation would turn that oracle into an assertion about the implementation.
  • A consumer-shadowing case points CONTEXT_KIT_PUB_LANG_DIR at a scratch dir whose rust.sh emits a marker row. It is the extractor seam’s end-to-end proof: the consumer-first resolution order and the bash spawn that executes a consumer extractor are both exercised, and the golden records the shadow’s output rather than the built-in grammar’s.
  • The arm registers as its own evidence-kit validate suite (index_tests, read by exit code), so the goldens have an automated validate-stage consumer.

No member here owes a port. The discriminator that released the last ones is this section’s own. A member that sources neither the runner nor a sequenced library is unblocked on its own ground, because a stated cause reaches only the members it names (gate-sdk/SPEC.md §Porting a gate to the binary substrate). A later session may not quietly add a member this section does not name, and the roster’s oracle is bash gate-sdk/bin/run-gates.sh --emit port-blockers --tree, not a count kept here.

The configured-command spawn has a suite of its own, gate-tests/session-context-spawn.test.sh (§The session-context hook). In a scratch repository it runs the member through the front end with programs for the brief commands and the stage-rules command. It asserts a started program’s line, a non-zero program’s line, the silence of one that cannot be started, the stage passed last, and the 127 report. Every path it hands the member is relative to that repository, so the gates workflow’s Windows crate-test leg runs the same file, the spawn’s run on a host where no shell stands between the member and the program.

The floor predicate has one holder (§bin/env-probe), so the crate’s own unit tests assert it, where a golden would be a second copy to drift. They cover its closed verdict set, the spellings of an unconstrained member, the uncomparable fail-closed arm and the audience axis’s present, empty and omitted forms.

The gates’ fixture pairs. check-brevity, check-surface-ratchet, check-settings-pins, check-memory-off and check-footprint-fresh carry the standard pair. The footprint emitter’s page is gated rather than runner-tested (§check-footprint-fresh). The settings-pins pair takes a --fixture <dir> injection (the check-identity precedent), reading <dir>/settings.json against <dir>/settings-pins.conf; the memory-off pair drives CONTEXT_KIT_MEMORY_DIRS from its own case-dir config, since that member has no fixture arm (§check-memory-off).

The direct unit tests hold the axes each pair fixes and so cannot express:

  • check-brevity.test.sh holds the section-set resolution. The pair fixes CONTEXT_KIT_BREVITY_SECTIONS at its one-element default and always supplies a file carrying it. So it cannot express an element resolving to nothing (exit 2, a broken machine rather than a clean tree), an unmatched element beside a resolved one, the empty set, a repeated element, a second section’s non-bold bullet, or the retired scalar knob’s refusal. The pair itself carries a non-bold bullet on each side, over budget with a pointer in bad/ and within budget in good/.
  • check-memory-off.test.sh holds the local-override axis, an untracked settings.local.json re-enabling a pinned key past an empty dir, which the pair’s fixed dir axis cannot.
  • check-settings-pins.test.sh holds the refusal axis. The pair holds only exit 0 against exit 1, so a pin outside the documented path grammar (a jq filter, an iteration, a slice, an array literal) needs its own case. It proves the refusal is exit 2, loud and naming the pin, the knob and the construct, never a silent clean verdict.
  • check-surface-ratchet.test.sh holds its member’s refusal axis, an absent ceiling file and an unparsable row each exiting 2, plus the ignored stale row. Its sandbox is outside any git repository, so every case leaves CONTEXT_KIT_RATCHET_PATHS empty. The pair, run inside the repo, drives the tracked-pathspec arm from its own case-dir config, which is what makes git ls-files resolve.

smoke/install.sh copies the templates into the scratch consumer (config into the gates dir, hook wiring into the harness settings, its placeholder resolved at the merge as the template’s note says, since the kit’s own check-settings-paths reads the merged file and reds an unresolved one), then:

  1. runs the hook end to end asserting exit zero and, when queue-kit is co-vendored, the queue index (the installer assumes only gate-sdk);
  2. runs it again on a stage cursor with a CONTEXT_KIT_STAGE_RULES command that cannot run, asserting the warning line and exit zero;
  3. runs run-gates.sh --emit always-loaded --update-baseline, asserting the baseline file appears;
  4. exercises the ratchet’s three states in order (§The surface ratchet): --ceiling then clean, a grown surface then red, a second --ceiling then clean again, unregistered, through gate_command, and disarmed again by deleting the ceiling file;
  5. drives check-settings-pins through its pass, violation and skip dispositions the same way, at the pins knob’s default path, on a pin derived from the first ident-named, non-null key of the settings file the install just wrote, so the recipe asserts no particular key is pinnable. The pass is asserted on the clean line’s pin count, since the absent-pins skip shares exit 0. It restores the settings file and deletes the pins file before handing on, because a later co-vendored install merges into the settings file and would leave a pin standing on a value it changed.

The ratchet stays unregistered because context-kit installs second and every later kit grows the agent file, so a ceiling stamped here would be stale before the green-battery assertion runs. Its disposition is on-surface, so the registration accounting’s own probe exempts it (gate-sdk/SPEC.md §Consumer smoke) and no # smoke-unregistered: reason is owed.

smoke/violation.sh crafts an over-budget pointered bullet in the scratch consumer’s brevity file and asserts the battery reddens via check-brevity. It inserts the bullet inside the first governed section, not at end of file: a co-vendored kit may append a trailing section (the doctrine-kit installer adds one), and an appended bullet would land outside the scanned sections and silently disarm the smoke.

Both scripts stay on the shell substrate permanently and carry # no-port:, under the class ruling at gate-sdk/SPEC.md §Consumer smoke, The port disposition. That ruling reaches them by its ground rather than its scope: its stated-contract cut covers the recipes answering to §Consumer smoke, and these answer here, but its legs 2 and 3 hold of them identically. The AGENTS.md smoke below is a standalone validate-suite driver, not an install or violation recipe, so neither leg reaches it and it is not a member of that class.

The AGENTS.md agent-file adapter smoke, the --agents-md-smoke arm (gate-sdk/SPEC.md §The non-gate arm), is the exercise behind the Tier-two compatibility claim (docs/positioning.md §The tiered compatibility claim). It vendors a scratch consumer through the shared consumer-smoke mechanics (gate-sdk/SPEC.md §Consumer smoke) and converts its agent file from CLAUDE.md to AGENTS.md. It sets the agent-file knobs in the consumer’s config seams (GATE_SDK_AGENT_FILE, LIFECYCLE_KIT_AGENT_FILE, DOCTRINE_KIT_AGENT_FILE, CANON_KIT_MANIFEST_FILES, CONTEXT_KIT_SURFACES, CONTEXT_KIT_BREVITY_FILE), then asserts:

  • the battery is green;
  • --emit always-loaded and the footprint emitter measure the AGENTS.md surface;
  • check-root-tiering’s built-in allowlist accepts AGENTS.md at root and rejects a stray second agent file.

It is a standalone harness, not driven by --run-consumer-smoke, which asserts the kit defaults under zero config, and registers as its own evidence-kit validate suite (agents_md_smoke).

  • The member is an Arm::Run and an arm-table row. Its contract is a verdict: 0 with the clean line, 1 with a FAIL — <reason> line, 2 for a precondition the harness could not meet. The validate suite reads that verdict through its EVIDENCE_KIT_PARSER setting (evidence-kit/SPEC.md §Layout and configuration owns the values), and an Arm::Emit could carry the report but not the verdict. It is spelled directly, as --upgrade-smoke is, since the --emit- prefix is load-bearing for the emit family alone (gate-sdk/SPEC.md §The non-gate arm’s correlation rule).
  • Its declared roster is GATE_SDK_KIT_DIRS and GATE_SDK_NATIVE_BIN, and no CONTEXT_KIT_ knob at all: the first names the kits to vendor, the second the binary to place. The agent-file knobs above are written into the scratch consumer’s own config seams and never resolved for the arm itself, which would hand it the host’s posture in place of the one it is constructing.
  • The vendoring calls gate-sdk’s in-crate scratch-consumer builder in process (gate-sdk/SPEC.md §Consumer smoke), so the smoke holds no second copy of the build. The arm routes the installers’ output to its stderr, keeping its stdout for the verdict lines.
  • After the vendoring, the arm regenerates CHECK-GRAPH.html. Each kit’s install.sh wrote it before the consumer’s canon-kit knob file existed, so its # graph: derivation used canon-kit’s bare default rather than the AGENTS.md-widened manifest set. The arm regenerates it under the env the battery will run with, or check-graph reds the consumer on an artifact stale by construction. That env carries LIFECYCLE_KIT_AGENT_FILE and CANON_KIT_KNOB_FILE into every spawned battery run, since the lifecycle knob is scalar with no default config file and canon resolves its manifest only through its knob file. A regeneration step the arm cannot complete exits 2: it is the arm’s own construction, not a finding about the consumer.
  • The root-tiering dispatch is resolved against the vendored tree, never by the arm for itself. A bash -c sources that tree’s lib/gate.sh and calls gate_command once, so the argv carries the consumer’s knobs and binary; only then is it run from a separate orientation-clean repo. A literal checks/<gate>.sh path would name a substrate the gate has left. A dispatch resolving to nothing is a FAIL, never an empty argv the arm tries to execute.
  • The scratch lifecycle is the arm’s own control flow rather than a trap. The arm cleans both scratch trees on every exit path. --keep retains both, printing the scratch consumer’s path, with the orientation repo beside it under ${TMPDIR}/agents-md-rt.*, findable by name. --keep is an argument the rule consumes, not a configuration selector, so it stays argv (gate-sdk/SPEC.md §The non-gate arm’s distinguishing test), and its usage line is the arm’s refusal text.

The expected-output runner is the section’s other Arm::Run, on the same grounds:

  • Its contract is a verdict: 0 with the clean summary line, 1 with the failing-check report, 2 on a harness error (no golden, a tool that exited non-zero), which the index_tests validate suite reads by exit code. Its spelling follows the variant (gate-sdk/SPEC.md §The non-gate arm’s correlation rule).
  • --update stays argv on --keep’s ground, and an unrecognised word is a refusal printing the usage line, never silently ignored.
  • It spawns the front-end, and that property must hold: each check reaches its arm through bash gate-sdk/bin/run-gates.sh --emit <name>, never through the binary directly. It spawns bash per check and nothing else, resolving the front-end and the fixture corpus out of the transported kit roots, since a compiled member has no BASH_SOURCE anchor. It places the child in the host checkout, since the front-end refuses outside a git repository.
  • Three sub-behaviours no golden holds are asserted in the arm. The consumer-shadowing case writes its scratch rust.sh to disk and passes CONTEXT_KIT_KNOB_FILE into a spawned child, never resolving it for the arm, on the AGENTS.md smoke’s ground. The refusal case asserts exit 2, the usage block on stderr and nothing on stdout for an unrecognised meter mode, an exit status and a stream no expectation file can hold. norm()’s path rewrite folds an absolute corpus path to the corpus-relative one; without it every golden holds an absolute path and the suite passes only on the machine that last ran --update.
  • Its scratch lifecycle is control flow rather than a trap: the arm removes its one scratch root on every exit path. Its declared roster is GATE_SDK_KIT_DIRS and no CONTEXT_KIT_ knob, on the smoke’s ground.

Out of scope

  • Product-shaped indexes, such as a proto-index over a proto layout or a diagram-index over architecture HTML: consumer surfaces, which a consumer names in its footer file.
  • check-md-refs, since an orientation-doc roster is rule content and the link-resolution mechanism is not this kit’s, and check-md-sections, since a required-heading map is rule content and queue-kit already gates the queue surface it guards.
  • A close-stage harvest pipeline ([pub] lessons, publication paths): product workflow, not context mechanism.
  • The drift report, drift-kit’s surface; only the always-loaded meter lives here.
  • A consumer’s session-context content (its delegation nudge wording, component roster and extra index commands), which stays in its own knob file and the files that names.
  • Memory content, by construction: the memory-off gates govern presence (the dir stays empty) and pins (the disabling keys hold), never a live session’s context, which is no scannable surface. A session polluted mid-flight is caught by the tree the gates hold, not by reading the session.