chore(#2995): extend fragment emission to agents/ and reclaim size-cap headroom (#3058)

* feat(#2995): extend fragment emission to agents/ across every read point

Epic #1671 Phase 6.4. `composeWorkflow` stripped `<!-- gsd:section -->` markers
only for `gsd-core/workflows/`, so a marked agent shipped its markers verbatim
into every runtime — and agent text is loaded into a subagent's context on every
dispatch.

The issue proposed widening the `copyWithPathReplacement` guard. That is a no-op
for agents: agents never traverse that function. Agent content is read for
emission at five independent points, and the obvious chokepoint
`stageAgentsForProfile` short-circuits on the DEFAULT `full` profile
(`skills === '*'` returns the real unstaged directory), so a hook placed there is
dead code on most installs.

Composition now happens at two call sites instead of five parallel surfaces:
`stageAgentsForRuntimeWithConverter` (with `agentsKind` and `kimiAgentsKind`
routed through it via an identity converter) and the inline agent loop in
bin/install.js. Both compose BEFORE any path rewrite, so a `.claude/` ->
`.windsurf/` regex can never reach inside a marker attribute — the ordering
#2930 established for workflows.

`installCodexConfig` was the fifth read point: Codex embeds each agent's prompt
into a per-agent `.toml` via its own readFileSync. Call-graph analysis missed it;
the exhaustive per-runtime emission sweep found it. That is why the new guard is
behavioral rather than structural — a sixth read point fails the sweep without
anyone remembering to extend a list.

tests/agent-fragments-emission.install.test.cjs spawns a real installer for every
runtime at every agent-bearing scope, derived from RUNTIME_META and the
capability registry at run time so a new runtime cannot be silently
under-covered. It asserts markers are absent AND the `when="always"` body is
retained, so marker-absence cannot be satisfied by dropping content. An
identity-composer negative control proves the assertion can fail.

Verified: 0 install failures, 0 marker leaks, body retained on 27 runtime/scope
paths; red before the wiring on claude(global+local), zcode(global+local),
kimi, codex and opencode.

Refs #2995

* chore(#2995): give the tightest agents headroom and correct the design lock

Epic #1671 Phase 6.4, second half.

`agents/gsd-verifier.md` had 12 bytes of headroom under its 49,152-byte LARGE
cap and `agents/gsd-debugger.md` had 147 under its 57,344-byte XL cap. Both now
extract reference material to `gsd-core/references/` behind an @-reference — the
documented DEFECT.AGENT-FILE-SIZE-CAP-BREACH remedy:

  gsd-verifier  49,140 -> 46,371 B   headroom    12 -> 2,781
  gsd-debugger  57,197 -> 48,851 B   headroom   147 -> 8,493

Byte accounting proves no content was lost: the combined agent+reference delta
is exactly the new files' headers plus the agents' slim replacement blocks. Each
agent keeps its routing table and a one-line summary per entry, so it degrades
gracefully on a runtime that does not inline @-references.

`agents/gsd-planner.md` is untouched and still passes both char guards
(49,130 < 49,152); it needed no change, so it took none.

The other nine LARGE/XL agents carry NO gsd:section markers, and that is
deliberate, not deferred. `when=` selection is read from
gsd-core/workflows/section-manifest.json, which gen-section-manifest.cjs derives
from gsd-core/workflows/*.md only — shape `{workflows: ...}`, no per-agent key,
no per-agent init entry point. An agent atom therefore fails admission gate (2)
("a fact the init seam demonstrably computes at a real entry point") and would
evaluate false forever while looking like working gating. Marking agents would
manufacture exactly the silent-inertness rot the frozen vocabulary exists to
prevent.

ADR-1671 gains three amendments, two of which close gaps /adr-phase-coverage
found against what actually merged:

  - The 19 -> 29 vocabulary widening shipped in #2994 with no coordinated ADR
    amendment, which that bullet's own rule forbids. Recorded now.
  - `flag:--verify-only` was one of six atoms #2992 withheld and deferred to
    "the LARGE/XL rollout phase". Five shipped; this one is permanently
    rejected, and that disposition lived only in a merged PR body.
  - Phase 6.4's own finding: emission extends to agents/, gating does not.

CONTEXT.md's glossary was stale on both seams — Workflow Fragments Module still
listed the original 4-atom vocabulary and described when= as "not yet acted on",
and Section Manifest Module still described InvocationFacts as
{waveFlag, phaseNumber, hasPriorPhases}. Both now match the shipped contract.

Inventory manifest regenerated AFTER build:lib per the documented ordering
landmine; 19 install-tree fixtures pick up the two new references.

Refs #2995

* chore(#2995): correct the compose-site count and mark the raw stager

Self-review found two comment defects in the prior commit. The agentsKind
comment claimed composition lands at TWO call sites; it is three, since
installCodexConfig's per-agent .toml writer was added after that comment was
written. And stageAgentsForProfile is now production-dead — both callers route
through the composing stager — while staying exported and unit-tested, which
makes it a trap: it does a raw copyFileSync and short-circuits to the unstaged
source directory under the default profile, so a future caller would silently
reintroduce the marker-shipping path. Its JSDoc now says so.

* test(#2995): guard the marker-documenting-doc class for agents

Widening the composer's scope to agents/ makes reachable the exact class #2930
narrowed scope to avoid: a file that DOCUMENTS the marker syntax with an
unfenced example is indistinguishable from a real marker, so the composer drops
that line from the emitted artifact.

Three rows. A fenced example must compose byte-identically. No shipped agent may
carry a marker outside a fence — asserted by parsing every real agent and
requiring zero explicit sections, which is what makes the fence protection
load-bearing rather than decorative. And a non-vacuity row asserts an UNFENCED
marker IS parsed as a real marker, so if that ever stops being true the second
row is guarding nothing.

Also applies two review findings: stageAgentsForProfile's new JSDoc claimed it
had no production caller, which is false — bin/install.js's _stageAgents still
calls it, and its consumers compose before writing. Corrected to state the
invariant instead. And a let/const nit in the emission sweep.

* fix(#2995): keep verifier status vocabulary in the agent, fix a wrong fixture

The first remote run came back red with three failures. Both root causes were
mine.

1. tests/agent-frontmatter.test.cjs requires agents/gsd-verifier.md to literally
   contain HOLLOW and DISCONNECTED. The Step 4b extraction moved that status
   vocabulary into gsd-core/references/verifier-wiring-patterns.md, so the agent
   no longer had it.

   Byte accounting said no content was lost, and byte-wise that was true — but a
   contract required those tokens to live IN THE AGENT. That is ADR-1671:66's
   flexReserve floor stated concretely: a load-bearing fragment must not be
   trimmed out of its host, and "the bytes still exist somewhere" is not the
   test. The two status tables are restored to the agent and deliberately
   mirrored in the reference with a note saying so, so the procedure there still
   reads standalone. gsd-verifier lands at 47,069 B — headroom 12 -> 2,083,
   rather than the 2,781 the first attempt claimed.

2. Row 12b of the new marker-documentation guard asserted that an unfenced
   marker example parses as a real marker, and threw instead:
   "unmatched /gsd:section close marker". The grammar is WHOLE-LINE only. The
   fixture had put the OPEN marker inline mid-sentence, so it was correctly not
   recognised as an open while the close, on its own line, was.

   That is a real refinement of the hazard this guard exists for: only a marker
   on its OWN line is mis-parsed — which is exactly how a documentation example
   is normally written. Row 12b now uses a whole-line marker, and a new row 12c
   pins the inline case as explicitly NOT a marker.

No test was weakened to accommodate the change; the change was corrected to
satisfy the tests.

Refs #2995

* chore(#2995): backfill changeset pr number to 3058

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-04 18:10:31 -04:00
committed by GitHub
parent 4eb8e3648c
commit ed360cd99f
34 changed files with 946 additions and 324 deletions

View File

@@ -28,6 +28,16 @@ const {
readGsdCommandNames: () => string[];
};
// #2995 (epic #1671 Phase 6.4): agent bodies join the fragment model. Markers are
// stripped at emit BEFORE any path rewrite or converter runs, so a `.claude/` ->
// `.windsurf/` regex can never reach inside a marker attribute and corrupt it —
// the same ordering #2930 established for workflows.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import workflowFragmentsModule = require('./workflow-fragments.cjs');
const { composeWorkflow: _composeWorkflow } = workflowFragmentsModule as {
composeWorkflow: (content: string, opts?: { sourcePath?: string }) => string;
};
// ---------------------------------------------------------------------------
// Profile definitions
// ---------------------------------------------------------------------------
@@ -360,6 +370,19 @@ function stageSkillsForProfile(srcDir: string, resolvedProfile: ResolvedProfile)
* For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner')
* is in resolvedProfile.agents — which is populated by resolveProfile() from
* the _calls_agents_* entries in the manifest.
*
* ⚠️ RAW STAGER — ITS OUTPUT IS NOT EMISSION-READY (#2995). This stager performs a
* plain `fs.copyFileSync` and — under the default `full` profile — short-circuits
* and returns the real source directory unstaged. It does NOT strip `gsd:section`
* markers. It is still called, by `bin/install.js`'s `_stageAgents`, whose output
* feeds the inline agent loop and `installCodexConfig`; both of those compose the
* content themselves before writing, so the raw output never reaches disk. What
* changed in #2995 is that `agentsKind` and `kimiAgentsKind` no longer use it —
* they route through `stageAgentsForRuntimeWithConverter`, which composes.
*
* The invariant to preserve: anything that takes this function's output and WRITES
* it as a runtime artifact must call `composeWorkflow` on each file first, or it
* ships markers verbatim.
*/
function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedProfile): string {
if (resolvedProfile.skills === '*') return srcAgentsDir;
@@ -834,7 +857,13 @@ function stageAgentsForRuntimeWithConverter(
continue;
}
}
let content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8');
const agentSourcePath = path.join(srcAgentsDir, entry.name);
let content = fs.readFileSync(agentSourcePath, 'utf8');
// #2995: strip gsd:section markers FIRST — before path rewrites, attribution,
// and the per-runtime converter. Byte-identical (no-op) for an unmarked agent;
// throws loudly naming the file for a malformed marker, never emitting a
// half-composed agent.
content = _composeWorkflow(content, { sourcePath: agentSourcePath });
if (agentCtx) {
// ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly)
// Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)

View File

@@ -20,7 +20,6 @@ import os from 'node:os';
import installProfiles = require('./install-profiles.cjs');
const {
stageSkillsForProfile,
stageAgentsForProfile,
stageAgentsForRuntimeWithConverter,
stageSkillsForRuntimeAsSkills,
stageCommandsForRuntimeFlat,
@@ -199,7 +198,21 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art
kind: 'agents',
destSubpath,
prefix,
stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved),
// #2995: a `converter: null` agents entry (claude local, zcode) previously
// staged via stageAgentsForProfile — a RAW byte copy that never reads content
// into JS, so gsd:section markers shipped verbatim. Route through the
// composing stager with an identity converter instead: same output as the raw
// copy for an unmarked agent, markers stripped for a marked one. Routing both
// agent kinds through the stager collapses what were five independent agent
// read points down to three compose call sites: this stager, bin/install.js's
// inline agent loop, and installCodexConfig's per-agent .toml writer. The
// exhaustive per-runtime sweep in tests/agent-fragments-emission.install.test.cjs
// is what keeps a fourth from appearing uncomposed.
stage: (resolved) => stageAgentsForRuntimeWithConverter(
findAgentsSourceRoot(configDir),
resolved,
(content: string) => content,
),
};
}
@@ -287,7 +300,13 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
root: { yaml: string; prompt: string };
subagents: Array<{ name: string; yaml: string; prompt: string }>;
};
const stagedAgents = stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved);
// #2995: compose at staging (identity converter) so the readFileSync below
// sees marker-free content — same single composing stager as agentsKind.
const stagedAgents = stageAgentsForRuntimeWithConverter(
findAgentsSourceRoot(configDir),
resolved,
(content: string) => content,
);
const subagents: Array<{ path: string; content: string }> = [];
if (fs.existsSync(stagedAgents)) {
for (const entry of fs.readdirSync(stagedAgents, { withFileTypes: true })) {