* feat(#2928): port CONTEXT.md predicate fact-store into the src seam Productionizes the ADR-1671 Option-E reference example as a real module: src/context-predicates.cts (parser + selector + index builder) compiled to gsd-core/bin/lib/, plus scripts/gen-context-index.cjs following the repo's --check/--write drift-guard idiom and wired into lint:generated-sync. Parser behavior is deliberately prototype-equivalent in this commit so the next commit's regression matrix binds to the real defects rather than to a missing module. Two locked design deviations from the prototype: - duplicates carry a count, not line numbers - the committed index carries no line field at all, resolving ADR-1671 open question 4: an artifact without line numbers cannot drift on a line shift, so promoting --check to a CI gate does not make it routinely red Also reconciles the one remaining duplicate predicate ID (RULESET.WORKFLOW_MARKDOWN.FENCES was declared twice; the non-MD040 wording is removed) so the gate can land fail-closed on duplicates. Refs #1671 * test(#2928): failing-first matrix for the predicate fact-store Adds the regression matrix from the phase test plan: parser declaration forms, fence and comment regions, ID/value grammar boundaries at limit-1/limit/limit+1, CRLF fidelity, duplicate detection, the drift-guard CLI, the selector query surface, and four document-shaped fast-check properties. Seven rows are RED for behavioral reasons against the ported parser: indented-bare, star-list, plus-list and numbered-list declaration forms are dropped; a tilde fence and a four-backtick fence containing a shorter fence are not skipped; and a multi-line HTML comment is parsed as live. Eleven selector rows are RED because the query surface is not wired yet. Negative fixtures come from real repo documents that predate the grammar (CONTEXT.md, CONTRIBUTING.md's fenced env-assignment examples) per the fixture-provenance rule, and the property generators are document-shaped rather than seeded from our own serializer. Refs #1671 * fix(#2928): consume the shared fence scanner, relocate the index, wire the selector Drives the failing-first matrix green. Parser: replaces the ported naive triple-backtick toggle with the shared markdown-sectionizer fence engine. scanFencedBlocks and FencedBlockRecord gain an export keyword — the only change to that module, which has 71 upstream dependents — because it already returns line-indexed spans, which is exactly what a line-reporting parser needs. It also already documents itself as the second copy of the fence state machine pending consolidation; adding a third copy here would have been the generative-fix divergence this repo warns about. A parity suite now pins predicate fence-skipping against that scanner across eight fence shapes. HTML-comment skipping stays local because the sectionizer has no comment scanner. Declaration forms widen to indented-bare, star, plus and numbered list items. Index location: docs/CONTEXT-INDEX.json, not a module under bin/lib. The remote matrix run caught the original choice — a committed .cjs there ships ~120KB of CONTEXT.md prose into a runtime module, and two content guards fired truthfully on it (a leaked .claude install path, and four hardcoded package-name literals). Neither guard was allowlisted; the artifact moved instead, mirroring docs/INVENTORY-MANIFEST.json. Nothing at runtime needs to require it — it is a drift-detection artifact, so the selector parses CONTEXT.md live and is always current. Generator: adds a frozen REASON enum and --check --json so the gate's outcome is asserted structurally instead of by matching prose, and --context-path/--index-path so tests drive the real CLI against a temp tree with no filesystem monkeypatching. Selector: gsd_run query context-predicates with --class/--prefix/--contains, structured output carrying a matched count, own-property guards, and no project-root resolution. Registering it exposed that the query dispatch table and the usage string had drifted: a new parity test found 20 routed commands missing from the usage list, all added here rather than deferred. Refs #1671 * test(#2928): lock the newly-public scanFencedBlocks contract Exporting scanFencedBlocks made it public API for the first time, so it needs its own contract test independent of the consumer that motivated the export. Memtrace's co-change analysis flagged the gap: this suite changes together with markdown-sectionizer.cts 8 times in 90 days and was absent from the diff. Covers the documented rules: 0-based indices, -1 for an unterminated fence, the same-char/>=length/no-trailing-text closer rule, a shorter fence inside a longer one staying content, CommonMark 4.5 backtick-in-info-string, and <=3-space indent tolerance. Refs #1671 * fix(#2928): address both isolated review passes Two independent reviewers (correctness axis and security axis, neither the author) found seven findings. All are fixed here with regression tests; none deferred. BLOCKER — comment-blind fence scanning caused silent, permanent predicate loss. The HTML-comment scan and the fence scan ran as two independent passes, and the fence scanner is comment-blind, so a fence delimiter inside an HTML comment with no later close read as an unterminated fence and skipped every remaining line to EOF. Worse, the drift-guard could not catch it: it diffs against a baseline produced by the same corrupted parse. The two constructs now interleave in a single pass so each suppresses the other's boundary detection while active, covered in both directions. The parity suite still binds this scanner to markdown-sectionizer's for comment-free documents, so the two cannot diverge unnoticed. BLOCKER — the selector was not consumed anywhere, leaving the phase's acceptance criterion unmet. Now wired into the pre-work predicate-citation step in contributor-standards, which is the repo's actual brief-assembly path; no code-level brief assembler exists to wire into. MAJOR — ReDoS with an unauthenticated CI-hang exploit. The predicate-id regex nested a dot-containing character class inside a dot-prefixed repeat, so N consecutive dots had exponentially many partitions: 40 dots took 565ms and growth was exponential. CI runs this parser over a pull request's own CONTEXT.md, so any contributor could have hung a shared runner with one line. Replaced with linear per-segment validation. Doubled-dot ids are now rejected; the real document contains none. MAJOR — the duplicate-id gate had only ever been proven on synthetic fixtures. A test now re-inserts the exact line this branch removed and asserts the real generator names it. MAJOR — --check together with --write silently let write win, turning the gate into a writer; a missing path value resolved to the cwd and leaked an EISDIR stack trace. Both are now clean usage errors. MINOR — the hoisted skip-list was exported as a live mutable Set; replaced with a read-only predicate. MINOR — flag-shaped selector values were unmatchable; the inline --flag=value form now provides the escape hatch. Refs #1671 * chore(#2928): backfill changeset PR number 2938 --------- Co-authored-by: sim <sim@local>
19 KiB
ADR-1671: Dynamic context management platform
- Status: Proposed
- Date: 2026-06-24
- Extends: ADR-0002 (Command Contract Validation Module), ADR-457 (build-at-publish generation model for
bin/lib/*.cjs) - Relates: ADR-857 §7 (Connected-Capability / MCP contract — kept deferred by this ADR)
Context
GSD ships command and workflow content as large, hand-edited Markdown files. Two structural problems compound:
-
Authoring is monolithic. A single workflow body carries every branch inline.
gsd-core/workflows/plan-phase.mdis 93,973 bytes / 1,770 lines;execute-phase.mdis 93,426 bytes. Mutually-exclusive paths (--prd,--ingest,--mvp,--reviews) all live in the same file, so a runtime loads guidance for branches a given invocation will never take. -
One payload ships to every runtime. Install copies the whole
gsd-core/tree (3.4 MB, 89 workflows, 1.7 MB) byte-identical to all 15 runtimes viacopyWithPathReplacement(bin/install.js). The only per-runtime work is string rewrites and description truncation. There is no per-runtime trimming or splitting.
The result is constant pressure against size caps, enforced today only against source files (not emitted output) by a two-part guard (issue #1074): a per-file baseline ratchet plus per-tier hard caps (workflows XL 96 KiB / LARGE 60 KiB / DEFAULT 40 KiB; agents XL 56 KiB / LARGE 48 KiB / DEFAULT 24 KiB). Several files have almost no headroom — agents/gsd-verifier.md has 293 bytes. The one true emission-time cap, Windsurf's 12,000-byte limit (src/runtime-artifact-conversion.cts), is a hard throw with no graceful fallback. Adding one rule to a tight file forces an extract-to-references/ refactor (DEFECT.AGENT-FILE-SIZE-CAP-BREACH), turning a one-line edit into a multi-file change that ripples across stub frontmatter, the workflow body, reference fragments, and docs/ — each guarded by a different lint.
A separate but related pain: the repo-root CONTEXT.md predicate fact-store (~935 lines, ~200 KB of CLASS.subkey=value predicates that agent briefs are required to "cite verbatim") has no programmatic reader, validator, or selector. Briefs are hand-assembled, and META.RULE.brief-must-cite-doc is enforced only socially — paraphrasing from memory has caused real violations (5/8 agents in one documented batch).
The machinery already exists, in silos
Research into the codebase found that most JIT primitives are already present and proven; they are just single-purpose and not composed:
- Lazy reference loading — the init bundle.
gsd_run query init.<cmd>(src/init.cts) returns JSON of paths + flags, not contents; the model reads only the files it needs ("paths only to minimize orchestrator context",plan-phase.md:66). This is Anthropic's recommended "lightweight identifiers over payloads" pattern, in production. - Progressive disclosure —
gsd-core/workflows/help.mdreads only the one mode file matching the argument (brief0.9 KB /default1.9 KB /full34 KB). - Token-budgeted assembly —
src/prompt-budget.ctsapplyBudget()already does priority-ordered, budget-trimmed composition with an omission note — but it is walled into the cross-AI review pipeline only. - Pointer-passing channel —
src/io.ctsspills any payload > 50 KB to a tmpfile and returns@file:<path>. - A codegen factory + drift-guard harness — 13 generators share one
--check/--writeidiom (derive fresh, diff committed, exit 1 on drift).scripts/gen-plugin-skills.cjsalready generates 69 shippedSKILL.mdfiles fromcommands/gsd/*.md. - A reusable structured-markdown parser —
src/markdown-sectionizer.cts, already powering the per-phase<decisions>fact-store reader (src/decisions.cts).
External practice
The closest external analogs are Anthropic Agent Skills' three-tier progressive disclosure (metadata → SKILL.md → bundled references), MCP resources/prompts/deferred-tools (list-then-fetch JIT), and priority/token-budget prompt renderers (Priompt, VS Code @vscode/prompt-tsx) that include the highest-priority fragments that fit a budget via a binary-search cutoff, with flexReserve floors for load-bearing content and <isolate> for a stable cacheable prefix. The portability catch is real and load-bearing: only the Skills format (directory + SKILL.md + frontmatter) is an open standard; native lazy loading is Claude-specific, and GSD's 15 runtimes do not all support skills or MCP (cf. surface-mismatch bugs #1614 antigravity, #1615 windsurf).
Decision
Adopt a dynamic context management platform built on a hybrid of build-time and run-time assembly, reusing the existing seams rather than inventing new infrastructure:
-
Fragment store (authoring model). Author workflow content as composable, priority-tagged fragments (workflow sections + shared
references/+ predicate-derived blocks), each carrying an applicability condition (which flags / capabilities / runtimes require it). This is the net-new authoring discipline. -
Build-time composer + per-runtime budget emission (the universal floor). Generalize
prompt-budget.ctsout of the review silo into a sharedcontext-composerseam (src/*.cts→build:lib→bin/lib/*.cjs). At build/install time, for each command × runtime, the composer selects the needed fragments and trims by priority to fit that runtime's measured cap (scripts/workflow-size.cjslfByteCount), emitting a right-sized artifact through the existing converter. Caps move from source to emitted output; the Windsurf 12 KBthrowbecomes a graceful auto-trim/auto-extract — noting that thisthrowis currently duplicated byte-identically in two surfaces,bin/install.js:2796-2797andsrc/runtime-artifact-conversion.cts:1116-1117, so the change must land in both or they drift. This is what makes caps stop biting on non-lazy runtimes, and it requires no runtime feature — so it is the universal floor. -
Progressive disclosure where the host supports it. On lazy-loading hosts (Claude Code and the Agent SDK), keep the stub +
@-refmodel and let the init bundle name exactly which files to read; the body and references load on demand. -
Run-time selection via the init seam (per-request precision). Extend the init bundle /
command-routing-hubdispatch (src/command-routing-hub.cts) to emit a typed manifest of which sections / references / predicates a specific invocation needs (given parsed args, flags, phase state, active capabilities), reusing the@file:spill channel for assembled fragments. This is layered on top of the fragment store. -
Formalize the
CONTEXT.mdpredicate fact-store → JIT selector. Give the predicate grammar a parser (onmarkdown-sectionizer), an ID-uniqueness validator, a--check/--writedrift-guard, and atask → relevant predicate setselector. This converts hand-assembled briefs into JIT-generated context and attacks the maintainer-side "edit a 200 KB file by hand" pain directly. This is sequenced first (see Prototype) because it is the smallest, lowest-risk piece that proves the whole pattern. -
Defer MCP (Connected-Capability). Per ADR-857 §7 / #956, a served MCP catalog (resources/prompts/deferred-tools) remains an additive future enhancement for MCP-capable runtimes — never a replacement for the file-copy floor. Not in scope here.
Options considered
| Option | Summary | Fixes caps? | Runtime compat | Decision |
|---|---|---|---|---|
| A. Progressive-disclosure authoring | Metadata-first files + one-level references; lean on host lazy-load | Partial; needs host lazy-load | Authoring universal; native JIT Claude-first | Adopt as a layer |
| B. Build-time composer + per-runtime budget emission | Composer trims fragments to each runtime cap, emits right-sized files | Yes — measured before write | Universal floor | Adopt as core |
| C. Run-time selection via init seam | Init bundle names which slices this invocation needs | Reduces per-invocation context | Broad (the gsd_run shim is universal) |
Adopt after B |
| D. MCP served catalog | Serve content as resources/prompts/deferred-tools | For MCP hosts only | Partial; needs 2nd channel | Defer (ADR-857 §7) |
| E. Predicate fact-store → JIT selector | Parse/validate/select CONTEXT.md predicates |
Maintainer-side big-file pain | N/A (build + orchestrator) | Adopt first |
Pure Agent Skills (A alone) and pure MCP (D alone) were rejected as the foundation because both are runtime-partial; only build-time emission (B) relieves caps on every runtime.
Architecture and contracts
- Fragment unit (open question, see below): either separate files (clean lazy-load + INVENTORY rows) or in-file section markers (
<!-- gsd:section ... -->, mirroring the existing<!-- gsd:loop-host -->markers consumed byscripts/gen-loop-host-contract.cjs). - Composer contract: priority + binary-search cutoff to a per-runtime budget;
flexReserve-style floors for load-bearing fragments (META.RULEcitation rules, contribution gates, closing-keyword rules); a byte-stable canonical prefix (<isolate>) kept identical across runtimes to preserve KV-cache warmth and keep launcher-parity tests green. - Budget unit: bytes for emission caps (matches
lfByteCount, deterministic, offline-safe); a token estimate for run-time selection. - Determinism + drift-guard: every generated artifact follows the universal
--check/--writeidiom and is committed; any constant shared between two surfaces gets aDEFECT.GENERATIVE-FIXparity assertion. Caps are asserted on emitted per-runtime bytes via real spawn-install tests (engine-direct tests are false-green for install behavior). - Boundary coverage: the composer's budget logic is tested at
cap-1 / cap / cap+1perRULESET.TESTS.boundary-coverage.
Migration path
Sequenced to de-risk — prove the pattern on the smallest surface first, scale last:
- This ADR establishes the platform, the fragment/composer contract, emission-time caps, and the drift-guard requirement.
- Prototype the predicate fact-store (Option E) — landed with this ADR as a non-shipping reference example under
examples/dynamic-context-management/(see Prototype below). - Lift
prompt-budget.ctsout of the review silo into a sharedcontext-composerseam with fast-check property tests + boundary coverage. - Pilot fragmentization on one XL workflow (
plan-phase.mdorexecute-phase.md): split into priority-tagged sections + applicability; composer emits per-runtime; prove byte-identical-or-smaller output and greengsd-testdocker. - Move caps from source to emitted output; turn the Windsurf
throwinto graceful auto-trim; auto-regenerate size baselines on intentional edits. - Wire the init bundle (C) to emit a per-invocation sections manifest; workflows consume it.
- Roll out across LARGE/XL tiers; update INVENTORY families + parity tests.
- (Deferred) MCP served catalog (ADR-857 §7 / #956).
Ordering landmine: any generator consuming compiled output must run after build:lib (tsc), like gen-plugin-skills / gen-capability-registry; regenerating before build:lib silently drops unbuilt modules (gsd-inventory-manifest-regen-needs-build).
Consequences
Positive
- Caps stop biting: each runtime's emitted artifact is measured and trimmed before write.
- A discovered fact lands in one fragment / predicate, not 4 hand-edited surfaces.
- Reuses the existing converter, drift-guard, boundary-test, and
markdown-sectionizerinfrastructure — the net-new pieces are only the fragment model and the composer. - Opens a path to collapse the 10+ hand-written per-runtime body converters toward a data-driven spec.
Negative / risks
- Trimming a load-bearing fragment is a correctness hazard (history: paraphrased
META.RULE→ agent violations). Mitigate withflexReservefloors, a Promptfoo-style eval gate, and boundary tests. - Per-runtime emission multiplies artifacts across the 15 × N matrix (inventory/parity surface).
- Build-order fragility (must run after
build:lib). - Dual-surface drift if any future MCP channel is added — requires parity assertions.
Prototype (step 2, Option E) — non-shipping reference example
A working prototype proves the platform pattern end-to-end. It ships as a reference example only, under examples/dynamic-context-management/ — deliberately outside the build (src/ → bin/lib/), the npm package files[], the installer, and the CI test suite (tests/). Nothing in it is compiled into or installed with GSD; the production implementation lands in a later phase.
examples/dynamic-context-management/context-predicates.cjs— pure parser/selector:parsePredicates(markdown)(handles bare and list-item backtick predicate forms, splits on first=, skips fenced code / blockquote prose, detects duplicate IDs),selectPredicates(predicates, {klass, prefix, contains})(the JIT "task → predicate set" selector), andbuildIndex(predicates)(deterministic, sorted).examples/dynamic-context-management/gen-context-index.cjs— self-contained CLI with--check/--writedrift-guard plus a--select <query>mode demonstrating JIT brief assembly.examples/dynamic-context-management/CONTEXT-INDEX.json— sample generated index: 416 predicates, 20 classes (regenerated 2026-07-31). Originally committed as 393 predicates, 18 classes (2026-06-24);CONTEXT.mdhas since gained thePROBE(11) andPROHIB(10) classes, withDEFECT161→167 andRULESET59→56. The committed artifact had gone stale (--checkexited 1) and was regenerated with--write.examples/dynamic-context-management/demo.cjs+README.md— runnable usage example and notes.
During research the slice was validated with 42 behavioral tests (predicate forms, fenced-code / prose skipping, duplicate-id detection, the selector, a deterministic index, and a fast-check property test); those return as CI tests under tests/ with the production implementation.
The prototype immediately surfaced 3 latent duplicate predicate IDs in CONTEXT.md (RULESET.WORKFLOW_MARKDOWN.FENCES, RULESET.GEMINI.TOOLS.ask_user, RULESET.GEMINI.TEST_SENTINEL) — integrity drift no existing tool catches.
Re-checked 2026-07-31: only one remains — RULESET.WORKFLOW_MARKDOWN.FENCES. The two RULESET.GEMINI.* duplicates were removed along with the Gemini runtime, not reconciled deliberately. Production --check can be made to fail on new duplicates once that single remaining ID is reconciled.
Phase 0 acceptance status (2026-07-31). The epic's Phase 0 criterion — "gen-context-index --check green in CI" — was unmet: --check exited 1 against next, and no CI job failed, because the example sits deliberately outside tests/ — the red was invisible to the pipeline. The index has now been regenerated and --check exits 0.
That is a point-in-time true-up, not a fix. Per Open question 4, the index is keyed on baked line numbers, so it will re-drift on the next CONTEXT.md line shift. The criterion stays fragile until the keying changes — and it stays silently fragile for as long as the drift-guard remains outside CI.
Prototype scope notes: the parser is intentionally self-contained for the example; production should consume the compiled markdown-sectionizer seam, live under src/ → bin/lib/, and be drift-guarded by a generator wired into the build after build:lib.
Done (#2928). Production landed under src/context-predicates.cts → gsd-core/bin/lib/context-predicates.cjs (ADR-457 build-at-publish). Fence-aware line skipping mirrors markdown-sectionizer.cts's exported scanFencedBlocks delimiter-matching rule exactly (byte-for-behavior parity proven by a dedicated test suite) via a LOCAL, interleaved single pass, rather than a call into that seam directly: a two-pass design (mask comments, then call scanFencedBlocks, or the reverse) cannot correctly resolve mutual precedence between HTML comments and fences in both directions — a fence delimiter inside a real comment (with no later real closer) was found to falsely skip the rest of the file to EOF, and the converse ordering falsely let a comment token inside a real fence leak past the fence's own close — so the two constructs are scanned together, each suppressing the other's open/close detection while active (post-#2928-review fix; see src/context-predicates.cts's module doc comment). scripts/gen-context-index.cjs --check/--write is wired into lint:generated-sync (so lint:ci, CI-gated) and into build (after build:lib) and regen:derived; the selector is exposed live via gsd-tools query context-predicates --class|--prefix|--contains.
Open questions
- Fragment unit: separate files vs in-file section markers?
- Build-time emission vs run-time assembly as the primary surface during migration (double-write vs per-workflow cutover)?
- Whether/when to invest in per-runtime native channels (skills, MCP) above the universal file floor.
Resolved by #2928 — index keying: stable IDs, with no line field at all. Question 4 asked stable IDs vs baked line numbers: CONTEXT-INDEX.json stored each predicate's line, so --check re-drifted on any CONTEXT.md line shift — a typo fix three sections up failed the gate. Raised by @davesienkowski (#1671, 2026-06-25). The shipped resolution is stronger than the option originally proposed (keying the comparison on stable IDs with line retained as non-compared metadata): the committed ContextIndex.predicates entries carry no line field at all. Committed-but-uncompared metadata goes silently stale — the same defect class the drift-guard exists to catch, with the alarm removed — so it was dropped from the committed artifact rather than merely excluded from the comparison. line is still returned by the live parsePredicates/gsd-tools query context-predicates result for callers that want to cite a source location; only the committed docs/CONTEXT-INDEX.json shape omits it.
Resolved by other work — not carried as open. A fourth question was proposed in review (#1671, 2026-06-25): what populates the eval-gate assertion set, and is it graded exogenously? Since that review, the answer has landed as first-class predicate classes rather than remaining a design gap: PROBE.principle (verifier-reach-equals-spec-reach), PROBE.family (edge-probe + prohibition-probe + ui-consideration-probe), PROBE.protocol (recall → precision), and PROHIB.judgment-tier (exogenous grading) — see ADR-550 D4/D7 and ADR-1606. The PROHIB.* predicates live in the same CONTEXT.md store this ADR formalizes, which is the single-store property that review asked for.
Related
- ADR-0002 — Command Contract Validation Module (the stub
<execution_context>@-ref contract this platform's emission must keep satisfying). - ADR-457 — build-at-publish generation model (the codegen + drift-guard precedent the composer extends).
- ADR-857 §7 — Connected-Capability / MCP contract (the deferred served-catalog channel).