Skip to content

feat(tracker): selection-scoped install bundle + tracker gap closure - #351

Merged
dean0x merged 49 commits into
mainfrom
feat/350-selection-scoped-install-bundle
Sep 22, 2026
Merged

dean0x merged 49 commits into
mainfrom
feat/350-selection-scoped-install-bundle

Conversation

@dean0x

@dean0x dean0x commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Summary

Half A — the selection-scoped install bundle. Devflow installed every skill from every plugin regardless of which plugins you picked, and overlaid all three tracker providers' generated mechanics onto every machine. A GitHub-only user carried 21 files and ~73 KB of Jira and Linear reference text they could never reach, and a Go project carried the React skill. Now each plugin hand-declares in a requires: field the skills it uses but does not own, the install set is the closure of skills ∪ requires over the selected plugins (32 of 40 for the default plugin set), and the tracker bundle is {github} ∪ {selected provider} — 13 generated references for github, 24 for jira/linear, with the tool-call contract references/tracker/_mcp.md and the Tracker agent file landing only for the MCP-backed providers. The build still compiles every provider, so the tarball is unchanged and switching providers needs no rebuild.

Half B — tracker gap closure. The provider dimension had holes that only a second provider makes visible. _mcp.md is now a fixed per-spawn load under a non-github provider; the Git agent reads both the per-repo key and the manifest before deciding and splits the mismatch DEGRADED reason by cause; release evidence is gathered in the provider's own reference grammar with project-key equality instead of a bare #N; issue references render through {ISSUE_REF}; two connected tool servers are scoped per capability with ambiguity reported rather than guessed; Reference Rendering gets a rule, a default and an end to a permanent DEGRADED; the Tracker agent's claim is observable (CLAIMED/LOST) and its compose chain fails closed. Then the transition-era test scaffolding was deleted, the phase-named ceilings collapsed onto one number each, and the goldens re-captured once.

What changed

Registry / installer / CLI

  • requires: readonly string[] on every PluginDefinition; skillsOf, skillOwners, buildScopedSkillsMap, resolveSkillInstallPlan, PRESENCE_GATED_SKILLS, TEMPLATE_SKILL_REFS in src/core/plugins.ts. buildFullSkillsMap is now a thin wrapper.
  • installedReferenceManifest({provider}) in src/core/mds-variants.ts — the INSTALL-side sibling of generatedReferenceManifest(), with an injectable modules seam.
  • New src/targets/claude-code/tracker-install.ts: convergeTrackerArtifacts owns the scoped overlay and the Tracker agent file, in both directions.
  • New src/cli/commands/install-report.ts: SummaryLine, formatOverlaySummary, describeOverlayFailureState, formatTrackerAssetSummary, formatSkillScopeSummary — the first three moved out of init.ts.
  • installViaFileCopy requires trackerProvider and takes optional effectivePlugins; InstallReport gains removedSkills, dormantShadows and unchangedRefs.
  • devflow tracker --set is runTrackerSet over a TrackerSetIO seam with a fixed converge order; --status gains a Mechanics: line; devflow skills list shows each skill's owning plugin; devflow uninstall --plugin retains from the manifest, not the registry.

Git agent + tracker references

  • Operations table loses its Key Parameters column and the ADR/PF/D4 restatements (−1,328 ch, which funded the rest).
  • _mcp.md is a fixed per-spawn load under non-github, named on exactly one line.
  • Provider resolution reads rungs 1 and 2 before deciding; the mismatch reason splits into (repository override) and (conventions file).
  • One project-key alphabet, ASCII-upper-normalised once at the key's own boundary.
  • Provider-grammar release evidence with project-key equality, plus the prohibition on reading the remote as a provider signal.
  • {ISSUE_REF} replaces the #-prefixed template slots; the template freeze is retired.
  • Two-server per-capability scoping: unique qualifying server wins, ambiguity is a new DEGRADED reason and no call.
  • Reference Rendering rule with a Refs {KEY}-{n} default; the plan artifact posts as content, and over the cap posts none of it.
  • New shared partial src/assets/mds/tracker/_common.mds holds the ref pre-flight heads and the shared provider lines.

Tracker agent

  • Observable claim outcome, fail-closed compose chain, one heartbeat, positive path shape, and a shape gate that keeps both anchors and adds a range link.
  • The presence sentinel is gated on whether a spawnable agent is actually on disk (agentPresent), not on whether this run happened to copy it — both write and removal go through one owner, applyTrackerSentinel.

Command layer

  • implement.mds and code.md take a provider-neutral issue argument and PR-link line; github-api.md defers to the D9 gate; /code-review's language focuses are presence-gated.
  • /implement routes $ARGUMENTS by token count rather than by its first word, so a free-text task description is no longer mistaken for an issue reference.

Build system

  • _jira.mds/_linear.mds import _mcp.mds/_common.mds by alias rather than selectively, working around an @mdscript/mds resolver cost that made those two modules' compile time scale with define count (npm run build:mds 9,967 ms → 873 ms); a new compile-time budget guard pins the fix.

Tests

  • New: requires-closure, install-shape, tracker-install, scoped-install-e2e, single-authority, reference-reachability, claude-md-tracker-section, build-mds-compile-time, compliance-gate.
  • Deleted: the containment oracle, its 918-line exemption registry, the byte-copied baseline tree and the guard census — with all five live controls re-homed and every known-bad probe re-pointed at a named sample before the deletion.

Docs

  • CLAUDE.md's Tracker paragraph becomes three end-state paragraphs at 3,477 characters with zero internal identifiers; "Universal Skill Installation" becomes "Selection-scoped Skill Installation"; README, CHANGELOG, the CLI reference, the skill catalog and the file-organization doc follow; six knowledge bases rewritten as end state (only files and symbols that exist on this branch).

Behaviour changes users will notice

  • Install footprint. The default plugin set installs 32 of the 40 plugin-owned skills instead of all 40. A github install carries 13 generated reference files; jira/linear carry 24 plus _mcp.md. The Tracker agent file is installed only for jira/linear.
  • Deselecting a plugin removes only the skills no remaining selected plugin owns or requires, and the summary names them. A shadow for a skill outside the selection is kept, not installed, never deleted — and the summary says so.
  • Jira issue keys are ASCII-upper-normalised. A reference typed proj-12 now resolves where it previously degraded.
  • devflow tracker --set exits 1 — leaving the manifest, sentinel and conventions untouched — when devflow:git is not installed or the reference overlay fails. It converges in both directions, so --set github removes what jira/linear installed.
  • devflow tracker --status gains a Mechanics: line with three outcomes: installed (N file(s)), MISSING — run devflow init, unreadable (<errno>).
  • devflow skills list says which plugin provides each skill and whether it is installed.
  • A steady-state devflow init re-run, or tracker --set <same provider>, now writes nothing. Previously every re-run re-promoted every reference file (mtimes advanced, Tracker assets: +N reference(s) printed every time) even though nothing had changed; each staged unit is now compared byte-for-byte against what's installed, unchanged units are skipped and reported in unchangedRefs, and the Tracker assets: line — including the Tracker agent file itself — prints only when something actually moved.
  • /implement routes its argument by token count, not by the first word. A single token (#42, 42, PROJ-12, a .md path) is still an issue reference or plan artifact; two or more tokens are now the full task description — previously /implement fix the login bug forwarded only fix as the issue lookup and silently dropped the rest of the request.
  • A home directory containing a space (or +, ~, @, ,, :) now suppresses only the tracker session-start directive, not Learning too. The two directives are gated on the paths each one actually interpolates rather than sharing one gate over both — the earlier shared gate silently disabled the entire learning pipeline on any such machine.

Reviewer decisions (2026-09-22)

  • git.md ceiling: option 2 — the phase-named pair (BUDGET_GIT_MD_P3 58,870 over the file, BUDGET_GIT_MD 55,750 − PREAMBLE_CHARS_P2 over the non-preamble remainder) stays as shipped. The two CHANGELOG.md lines now state that gate (0563a62). The downward collapse follows issue git.md: second size cut — move the 8 PR/review op bodies to references (SG-7) #326.
  • Reference Rendering placement: accepted as shipped — authored once in _mcp.mds, expanded into the four consuming operation files, absent from the emitted _mcp.md.

Held for reviewer decision (resolved above)

1. The git.md ceiling collapse. The plan bounded the sign-off: "if measured git.md > 56,400 STOP and report."

term value
measured dist/agents/git.md 58,100 ch
bounded collapse target 56,400
over the bound by 1,700 ch
live gate BUDGET_GIT_MD_P3 58,870 (headroom 770 — green)
declared base BUDGET_GIT_MD 55,750
PREAMBLE_CHARS_P2 3,385
non-preamble allowance 52,365
measured non-preamble 51,076 (headroom 1,289 — green)

Nothing is red. The conflict is with the collapse target, and a Code agent cannot pick between the three options:

  1. Authorise the collapse at measurement + ≤60 — one BUDGET_GIT_MD at ≈58,160, plus BUDGET_GIT_MD_NON_PREAMBLE re-derived from the measured 51,076 (e.g. 51,140). BUDGET_GIT_MD_P3 and PREAMBLE_CHARS_P2 are then deleted. This raises the registered budget-git-md ceiling 55,750 → 58,160, a second authorised re-baseline beyond the one already granted for the loaded-set rows.
  2. Keep the phase-named pair as it stands. Costs nothing, is green, and leaves two *_P3/*_P2 names in the end state the end-state sweep would otherwise have removed.
  3. Fund ~1,700 ch of further git.md cuts (issue git.md: second size cut — move the 8 PR/review op bodies to references (SG-7) #326) first, then collapse onto a number at or under 56,400. The only option that meets the bound as written; it is a content change in git.mds, i.e. a new prompt-asset window.

Consequence of holding, recorded: PREAMBLE_CHARS_P2 and the non-preamble arm were not deleted although the phase brief listed them, because they are the operands of the two-sided gate. Deleting them while the collapse is held would leave git.md gated only at the file level — strictly weaker with nothing replacing it.

Also unresolved by the same hold: CHANGELOG.md lines ~35 and ~47 still cite BUDGET_GIT_MD 55,750 / 55,664 characters. Whichever of the three options above is taken, those two lines need the matching correction.

2. Reference Rendering rule placement. Gate 1 of the design review said the rule and its gate belong in _mcp.md, with the default value in each provider file. What shipped authors the rule once — @define reference_rendering_gate in _mcp.mds — but expands it inline into the four consuming operation files; it appears zero times in the emitted _mcp.md itself. This kept _mcp.md inside its own byte budget, and the define is registered as a SHARED_RULES single-author row, so there is exactly one place to edit the rule text and no drift is possible — but it is not the placement Gate 1 asked for. Needs an explicit accept, or a re-placement that would cost jira/linear loaded-set bytes (both already re-baselined once this branch).

Deviations from the approved plan

  • The wholesale ~148-pin reclassification sweep was not performed. Pins were classified per section, in the commits that rewrote the prose — softening pins a phase had no reason to touch would widen a guard corpus for its own sake. If a wholesale pass is still wanted it is a separate commit, argued per pin.
  • Two Scrutinize fixes landed stronger than the plan asked, and both directions are now tested. Plan item C2 called for an asymmetric sentinel gate — WRITE gated on converged, REMOVAL always attempted. What shipped (f405853) gates both write and removal on the same thing, whether a spawnable agent is actually on disk (agentPresent) — because converged answers "did this run copy the file", not "is a working agent present", and reading it disabled a working jira install on a transient re-copy failure while leaving a stale sentinel behind on a jira→linear transition. Plan item M6 said the session-start-context positive-shape control lands in P1 with its own tests; what shipped there (a1c993c) found and fixed a real regression in that control instead — it had gated the Learning directive on a value only the Tracker directive interpolates, silently disabling Learning on any home directory containing a space — and split it into one gate per directive-consumed value. Both fixes are proved against both transition directions (github↔jira↔linear for the sentinel; both directives for the hook gate), not only the one direction each original plan item named.
  • The ref pre-flight heads were hoisted into _common.mds, not _mcp.mds. Measured: compiling a provider module against _mcp.mds costs 3.3 s at 9 defines, 8.3 s at 11, and does not finish at 12. The same defines in _common.mds cost 1.2 s. Design review L1's ownership rule is amended in both module headers rather than ignored.
  • Four single-line pre-flight defines rather than one with a mode parameter. MDS @if is block-structured and terminates the line; all fourteen call sites are fragments mid-list-item.
  • The tool-name prefix could not be spelled in the generated tree — the provider-scope guard forbids vendor tokens across the Git spawn surface — so the partition rule reads "the leading namespace segment of the tool name".
  • TRACKER_GITHUB_OPS and trackerConventionsBackupName/Path were kept, not deleted: the first already has a consumer, the second is pinned by tests and is a user-visible filename contract.
  • requires-closure-token-floor was registered although one phase row scoped numeric-floors.json to two entries — a floor living only in a test constant can be lowered silently. Value re-derived 37 → 44 from the widened corpus.
  • tests/scoped-install-e2e.test.ts lives in tests/, not tests/integration/vitest.config.ts excludes tests/integration/** from the default suite, so the planned path would have produced a proof nobody runs.
  • --set's I/O was extracted into runTrackerSet + TrackerSetIO because the planned recording stub cannot be written against a Commander .action() body.
  • GIT_AGENT_BYTES lives in git-agent-golden.test.ts, not where the brief placed it, so that file joined the fixture-only commit.
  • The optional phase-coordinate narration strip was not made — see Known follow-ups.
  • collectDegradedReasons was fixed to balance parentheses while splitting the mismatch reason: the old alternation could never fire, returned a truncated reason and reported it as unregistered.

Known follow-ups

  • §14.x phase-coordinate references remain in ~28 test files (previously stated as four — later commits on this branch added more test files carrying them than the original count captured) and 6 src/ JSDoc sites (src/core/feature-config.ts ×4, src/core/mds-variants.ts, src/assets/scripts/redact-secrets.cjs). Argue per site, not per count.
  • code.md is a third authority for each provider's reference grammar. Accepted because the Code agent is outside the Git spawn surface and loads no mechanics file it could defer to; if a future change gives it a loadable reference, those arms move into it.
  • The @mdscript/mds resolver still deep-copies a selective import's captured scope on every @define in the importing module. This branch worked around the one cliff it hit (_jira.mds/_linear.mds importing _mcp.mds and _common.mds) with alias imports plus a compile-time budget guard, but the underlying resolver defect is unfixed — a future module with the same shape hits the same wall. Upstream fix belongs in collect_define, a per-module Arc<CapturedScope> instead of a deep clone per define.
  • X-RateLimit-Remaining is still restated, rather than pointed at its authority, at the skill/doctrine layerdevflow:git's own SKILL.md and references/github-api.md — outside the tracker-generated tree the single-authority guard covers. Pre-existing, not introduced by this branch; 68f2ba3 fixed only the one restatement inside gather-release-evidence.
  • Linear's dedup unavailable — duplicate possible is permanent on a stock workspace: devflow cannot ask which account it is, so a back-link posts with the warning rather than being withheld.
  • Issue git.md: second size cut — move the 8 PR/review op bodies to references (SG-7) #326 — the ~1,700 ch of further git.md cuts the bounded collapse needs.
  • The reason-table floor in tests/tracker/schema-scope.test.ts is still unregistered in numeric-floors.json — it lives only in the test file itself, currently 21. A one-entry addition whenever someone wants it.
  • One test failed once in a targeted run and has not reproduced since, in isolation or in the full suite. Not named because it could not be pinned to a specific arm; watch for a recurrence rather than chase it now.

Verification

  • Full suite (npx vitest run --exclude tests/eager-memory-refresh.test.ts): 155 files / 5,518 tests, exit 0.
  • npm run build and npx tsc --noEmit: clean.
  • CI: green on 7bc70f1, 463f4e0, 80f33ab, 8e8a0c4, 772f052; the run for 57a618c was still in progress at time of writing.
  • Gates run this cycle, all in scratch HOMEs: Validate ×3, Simplify (one dedup pass, no behaviour change), Scrutinize (5 fixes), Evaluate (MISALIGNED → 7 fixes → ALIGNED), QA (11 scenarios; 2 fix loops: S4c, S2).
  • Golden re-capture: 9 changed lines, all 9 explained — five {ISSUE_REF} template sites (fixture 48/52/61/86/99), two release-evidence steps (165/166), two code.md PR-link lines (239/240). Line counts identical before and after (250 lines, 249 newlines), so there is no offset drift. npm run test:golden:update was never run: both fixtures are byte copies of an explicit --unfreeze --out-dir <scratch> output. The authorisation is spent; none is outstanding.
  • Ceilings, old → new: BUDGET_LOADED_SET 77,824 → 80,200 (measured 80,155); BUDGET_LOADED_SET_JIRA 88,660 → 89,500 (89,459); BUDGET_LOADED_SET_LINEAR 91,000 → 91,700 (91,642); PREAMBLE_MAX_LINES 40 → 36 (35 lines); BUDGET_LOADED_SET_P3 deleted; GIT_MD_CHARS 58,818 → 58,100; GIT_AGENT_BYTES 59,279 → 58,549; FIXTURE_BYTES 17,527 → 18,270; GITHUB_API_MD_CHARS 21,218 → 21,166. budget-git-md and budget-git-md-p3 are untouched and green.
  • Floors: 29; ceilings: 9 (claude-md-tracker-block-max-chars added).
  • Every RED was proved before its GREEN, including the docs guard (5 of 6 assertions failed against the previous CLAUDE.md) and four temporary ceiling probes that were run and removed, never committed.

Only a live claude run can answer these three — named, not scheduled: that a jira spawn reads references/tracker/_mcp.md exactly once and renders Refs PROJ-1; that /code-review skips a language focus whose skill is absent; and that a real concurrent subagent claim race produces one CLAIMED and N−1 LOST.

Related Issues

Closes #350
Refs #321 #325 #343 #345 #346

…l closure guard

Plan step A1 (steps 1-2) of the selection-scoped install bundle.

Skills become plugin-scoped, as rules already are. Each plugin declares the
skills it USES but does not OWN in a new `requires:` field; what a selection
installs is `skills u requires`. The 13 non-trivial tables are evidence-derived
over the corpus a selection actually installs - dist/commands, agent sources,
and SKILL.md + references/** of every skill already in the closure, iterated to
a fixed point (design review C1). The default non-optional selection drops from
40 skills to 32; the eight language skills install only with their plugin.

New pure surface in src/core/plugins.ts:
  skillsOf / skillOwners / buildScopedSkillsMap / resolveSkillInstallPlan
  PRESENCE_GATED_SKILLS (derived) / TEMPLATE_SKILL_REFS
buildFullSkillsMap becomes a thin wrapper over the scoped map.

resolveSkillInstallPlan is the pure install/remove/dormant-shadow decision
(design review H4) so installViaFileCopy consumes a decision rather than
growing a fourth set of inline set-arithmetic. A --plugin run removes nothing
(AC-22); FEATURE_OWNED_SKILLS are never swept (applies ADR-024); a shadow
outside the selection is reported dormant and never deleted.

Language skills are never required (AC-25): /code-review now presence-gates
each language Review focus on ~/.claude/skills/devflow:{focus}/SKILL.md, the
same gate COMPLIANCE_SKILL_INSTALLED already uses. TEMPLATE_SKILL_REFS declares
the one classified exception - the prefix-less focus-skill template - at the
declaration site of the field it exempts, and the guard imports it rather than
restating it (applies PF-067, design review M1).

Guards:
  tests/guards/requires-closure.test.ts - forward (every reference resolves in
  scope), reverse (no dead requires entry), structural, and the presence-gate
  counterpart, each with synthetic known-bad probes (applies PF-018). Token
  floor 44 registered in numeric-floors.json.
  tests/guards/retired-wording.test.ts - new class rule over src/**: wave
  coordinates in source comments are transition narration (applies ADR-003).
  Seven sites rewritten to state the rule instead of the phase; the corpus is
  widened to the .ts/.cjs trees the entry names, and the pattern form is
  reserved for residue whose spellings are not enumerable (applies ADR-025).

Refs #350
…}, _mcp.md iff MCP-backed

Plan step A2 (step 3) of the selection-scoped install bundle.

The build emits every provider (34 files) so the tarball can serve any
selection without a rebuild. An install should not: a github user gains 21
files / 38 KB of jira and linear mechanics they cannot reach. installedReference
Manifest({provider}) is the narrower manifest the overlay converges to —
{github} u {selected provider} plus the provider-independent cross-cutting
documents, with tracker/_mcp.md riding the same MCP_BACKED_PROVIDER_SUBDIRS
gate its generation already rides.

The GitHub tree is the FLOOR under every provider, not an optional extra: PR
hosting stays on GitHub whatever the issue tracker is. PR_HOST_TRACKER_SUBDIR
states that as a fact about where pull requests live rather than deriving it
from "the one CLI-backed module", which would read as a rule and silently
promote the next CLI-backed provider into everyone's install.

Derived from the registry, never from a provider table: a provider registered
with a tracker/{id} subdir is installable by construction. Keeps its sibling's
posture and asserts on a registry that does not expand (design review M3) —
the registry is a compile-time constant, so a refusal is a programming error no
caller could sensibly continue past.

Measured 2026-09-19: github 13 files / 31,399 B; jira 24 / 69,252 B;
linear 24 / 73,501 B. Registered as installed-reference-count-github=13 and
installed-reference-count-provider=24; the two build-time 34-floors are
annotated as build/tarball scope with their values untouched.

Refs #350
…t file and the scoped reference overlay

Plan step A3 (step 4) of the selection-scoped install bundle.

Two artifacts converge with the tracker provider, and they get two owners on
purpose (design review M2). convergeTrackerArtifacts owns the Tracker agent
file alone and carries no overlay mode flag; overlayInstalledReferences is the
installer's export, because the installer already has exactly one overlay
spelling and a flag would have made it two. `devflow tracker --set` calls both
explicitly, in the fixed order.

src/targets/claude-code/tracker-install.ts mirrors compliance-install.ts:
absolute-claudeDir precondition asserted in production code, warn-not-throw per
PF-009, converged=false on any warn path so the sentinel write can be gated on
it, and the biconditional converging in BOTH directions per PF-015 — selecting
jira or linear installs the agent, returning to github removes it. The agent is
re-copied on every converging run, so a truncated or hand-edited file self-heals
rather than being trusted because it exists.

`tracker` stays declared in devflow-core-skills.agents and is filtered here at
install time. A FEATURE_OWNED_AGENTS set would cost three new union sites and a
rewrite of the pinned agent-roster floor (17, unmoved) to solve a problem the
agent sweep does not have: the sweep keys on the full registry, so it never
sees this file as an orphan.

overlayInstalledReferences resolves the install manifest and the target from a
claudeDir and a provider and changes nothing else. Its `referencesRoot` option
is the seam that makes the absent-generated-tree refusal provable without
deleting dist/ out from under a concurrent run (applies PF-013); the refusal
still lands before the target directory is created, so a refused overlay leaves
the install as it found it.

Refs #350
…rovider-scoped overlay, tracker-aware summary

Plan steps A4 (steps 5-6) plus design-review C2, H4, H6, L2, M5 and M6.

installViaFileCopy now takes a REQUIRED trackerProvider. Required rather than
defaulted because the overlay converges: a caller that forgot to pass one would
not install a slightly wrong set, it would delete the previous provider's
mechanics on every install. There is no safe default for a destructive
convergence, so the type refuses to guess.

The skill decision is made in full, before anything is touched, by the pure
resolveSkillInstallPlan (H4) — install set, removal set, dormant shadows. The
removal set is registry arithmetic (`skillsOf(all) \ skillsOf(selected) \
FEATURE_OWNED`), never a readdir of the install tree, so a directory a user put
there by hand is not swept as a deselection (applies ADR-024); a name the
registry has lost entirely still leaves through the orphan sweep, which reports
it as the different fact it is.

The pre-clean is now SCOPED to what the run reinstalls and gated on a full
install, while the orphan sweep stays UNSCOPED. The opposite scoping is
deliberate and is recorded at the site: the sweep removes names the registry no
longer has, which is true regardless of selection; the pre-clean empties a
directory this run is about to rewrite, so widening it past the install set
would delete a selected plugin's skill and never put it back.

init.ts resolves the effective plugin list BEFORE the install rather than at
manifest-write time, so the skills install set and the recorded selection have
one binding and cannot disagree. Skills move from buildFullSkillsMap to
buildScopedSkillsMap(effectivePlugins); agents and rules stay on the selected
plugins as before.

persistManifestThenConvergeTracker gains the fourth owner. convergeTracker
Artifacts runs SEQUENTIALLY and strictly before the rearm/sentinel pair, which
stays a Promise.all rather than being flattened to make room (H6). C2: the
sentinel WRITE is gated on the agent converging — nothing advertises a provider
whose agent is missing — while the sentinel REMOVAL is always attempted, because
a stale sentinel costs every future session a fork for a provider the user has
left and a failed agent removal is not a reason to keep paying it.

M5: formatOverlaySummary and describeOverlayFailureState move to the new
src/cli/commands/install-report.ts, joined by formatSkillScopeSummary and
formatTrackerAssetSummary. `devflow tracker --set` renders the same overlay
outcomes, and a CLI command importing a renderer out of a 2,450-line sibling
couples two commands through a file neither owns. L2: the upgrade-removal line
fires only when a prior manifest exists AND its plugin set is unchanged — a
first install removed nothing, and a user who deselected a plugin asked for it.

M6: session-start-context's directive path gate becomes the positive shape
^[A-Za-z0-9/._-]+$ instead of a denylist of four characters someone thought of.
An allowlist refuses the next escape nobody has thought of by construction. The
narrowing is real — a home directory with a space now suppresses the directives
rather than interpolating an unproven value — and that is the fail-closed
direction.

New: tests/installer/install-shape.test.ts (install set, removal gating, shared-
requires survival, sweep-vs-pre-clean scoping, shadow dormancy, summary lines).

Refs #350
…--status reports installed mechanics

Plan step A5 (step 7) plus design-review C2, C3, H1 and M2.

`devflow tracker --set` now converges the whole bundle, not just the three
~/.devflow files. The step order is the invariant and is asserted as an order,
through an injected TrackerSetIO the way init.ts's TrackerLifecycleIO already
is:

  probe devflow:git -> overlay references -> rename stale conventions ->
  persist manifest -> converge the agent file -> rearm -> sentinel

The overlay precedes the manifest write and the agent file follows it. That
asymmetry is the point: the reference subtree is INERT — nothing loads it until
a Git spawn resolves a provider, and resolving a provider reads the manifest —
so installing it early costs nothing and lets a failure abort cleanly with the
previous provider whole. The agent file and the sentinel ADVERTISE a provider
and must never get ahead of the manifest recording it. init's order differs and
stays differing: two call paths, one principle, and a shared signature would
hide the reordering rather than document it.

C3: an absent devflow:git aborts with exit 1 and the same end state as an
overlay failure — no manifest, no sentinel, and no references/ husk under a
skill nothing installed. H1: the overlay-failure message says the manifest,
sentinel and conventions are unchanged and that the overlay is atomic per unit,
so the units that succeeded are converged and only the failed ones are
reported. "Nothing else changed" is the more comfortable sentence and the false
one.

C2: the sentinel WRITE is gated on the agent converging; the sentinel REMOVAL is
always attempted, because a stale sentinel costs every future session a fork for
a provider the user has left. M2: --set calls overlayInstalledReferences and
convergeTrackerArtifacts explicitly, in order — no mode flag on either.

Repeating the current provider is not an early return: it re-runs the overlay so
a damaged subtree self-heals, and reports `(unchanged)` when nothing moved.

`--status` gains a Mechanics row with three arms — installed (n) / MISSING /
unreadable (errno). Three, not two: "no files" and "could not look" have
different remedies, and collapsing them tells a user to re-run devflow init over
a permissions problem init cannot fix.

The single-owner source guards move with the bindings: each file-lifecycle owner
is bound exactly once in buildTrackerSetIO, the orchestrator reaches every one
of them through io (asserted, so a direct call cannot bypass the order test),
and the CLI still never touches .tracker.enabled or .tracker.attempts itself.

Refs #350
…rom the manifest, not the registry

Plan step A6 (steps 8-9) plus design-review L3.

skills.ts. Once install is scoped, the question `devflow skills list` has to
answer is "which plugin do I select to keep this?", and a first-wins owner
column names one plugin out of several that would each do. Every row now names
every declarer and says whether the skill is actually installed — for a skill
that is not, that is the only useful thing the row can say. L3: the declarers
are resolved ONCE into a Map before any row is rendered, rather than walking the
registry per row for an answer that does not change between rows.

`devflow skills shadow <name>` no longer refuses a skill outside the selection.
"Not installed" is now an ordinary state for a registry skill nobody selected,
so the shadow is seeded from the shipped source and reported as dormant: it
exists, every future install preserves it, and it applies to nothing until the
plugin that uses it is selected. The warning names that plugin.

uninstall.ts. computeAssetsToRemove took the whole REGISTRY as the retained set.
Once skills are plugin-scoped the registry and the manifest stop agreeing, and
retaining on behalf of plugins the user never installed means
`devflow uninstall --plugin=X` keeps X's skills alive because some unselected
plugin also declares them — leaving the user with exactly the files they asked
to remove. It now takes what the manifest records, resolved once and handed to
BOTH the dry-run and the real removal, because a preview computed from a
different list is a preview of a different uninstall. Retention is across the
closure (`skills ∪ requires`) for the same reason the install set is: a skill an
installed plugin merely requires is still a skill it needs. No manifest, or one
naming nothing this registry still has, falls back to the registry — retaining
too much is the safe direction for a removal.

The three tracker runtime basenames come from src/core/tracker.ts instead of
being retyped as literals.

Two audit items resolved as no-change, with the reason recorded at the site:
  - TRACKER_GITHUB_OPS is kept. It has a live consumer (the github row of
    VARIANT_MODULES) and its JSDoc is what stops the roster reading and the
    provider-scope reading being confused at the guard sites that mean one and
    not the other.
  - trackerConventionsBackupName/Path keep their exports, now stated as
    deliberate: the .bak filename is a user-visible contract, and un-exporting
    would force its pin to re-derive the name beside the real one, which is the
    shadow reimplementation a guard is worth nothing without (PF-018).

Refs #350

Also lands tests/scoped-install-e2e.test.ts, the end-to-end proof for the whole
of Part A: it drives the real dist/cli.js (init per provider, tracker --set both
directions, --plugin, uninstall --plugin) against mkdtemp HOMEs bound into the
spawn env (PF-060). The unit tests all call installViaFileCopy with hand-built
maps and so cannot see a wiring mistake between the CLI and the installer, which
is the only place every claim this wave makes is actually true or false. It sits
in tests/ rather than tests/integration/ because the default vitest config
excludes that directory, and a proof nobody runs is not a proof.

Folds in one cross-file fix the full-suite run surfaced: three synthetic probe
names in the new guard and install-shape suites were spelled as `devflow:`
source literals, and tests/skill-references.test.ts scans every tests/**/*.ts
for exactly that — a `devflow:` name that is not a registry skill. The probes
need those names on DISK and in the strings they feed the collector, not in the
source text, so the prefix is now composed. The probe input is byte-identical;
only this file's spelling moved, so the global scanner stays strict rather than
gaining an exemption for the two files whose subject is invalid references.
…ion registry

Five of the seven describes in tests/tracker/containment.test.ts are LIVE
controls over the shipped tree — they read dist/, not the 101bda7 baseline.
They move ahead of the file's deletion so nothing is silenced in between.

New homes:

  tests/tracker/single-authority.test.ts
    - MIN_RATIONALE_CHARS, and a named collectUnderJustified() both registries
      read (the constant arrives with the two registries it governs)
    - SHARED_LITERAL_REGISTRY + "one authority per normative sentence"
    - MCP_SHARED_LITERAL_REGISTRY + "tool-call contract: one authority"

  tests/tracker/reference-reachability.test.ts
    - structural parity (every op has a file, every file has an op, every file
      opens with its own `## Operation:` anchor)
    - gather-release-evidence batch-first
    - AC-2.7 reachability on the gh path

Two changes, both classified rather than swept (applies ADR-025):

  1. SHARED_LITERAL_REGISTRY's justification check was emptiness-only
     (`length === 0`); it now reads the same MIN_RATIONALE_CHARS floor the MCP
     registry reads. The constant's own doc argues that `justification: 'x'`
     records a keystroke rather than a reason, and leaving the weaker check two
     screens below that doc is how the two drift. STRENGTHENED, with a probe
     driving collectUnderJustified over seeded entries so the floor is proven
     live (PF-018).

  2. The gather-release-evidence known-bad probe read the pre-rewrite fan-out
     line out of tests/fixtures/tracker/baseline/git-agent.md. That tree is
     deleted in the next commit, so the one line it contributed here is now a
     named PER_COMMIT_FANOUT_SAMPLE constant, quoted byte-exact, plus a
     discrimination arm proving a batch-first line is NOT reported. Same
     collector, same shape, no whole pre-rewrite document kept to hold one line.

Exemption-registry partition (PF-067):

  (i)  transition-era — all 75 CONTAINMENT_EXEMPTIONS entries. Each addresses a
       1-based line range of a baseline fixture; with the baseline gone the
       ranges address nothing. Deleted with the oracle in the next commit.
  (ii) exemptions backing a live prohibition — EMPTY, and stated as empty rather
       than filled. The rationales that cite PF-027 and PF-063 cite them as the
       REASON a line was cut or demoted; neither prohibition is enforced from
       this table. PF-027's D11 comment-sink control is asserted in
       tests/tracker/byte-budget.test.ts ("written exclusions"), and its
       inline-body exemption registry (KNOWN_GITHUB_API_INLINE_BODIES, asserted
       in both directions and deliberately empty) already sits beside the
       prohibition in tests/git-agent.test.ts. PF-063's heading-grammar
       prohibition in tests/tracker/reference-structure.test.ts is absolute over
       unfenced `## ` and carries no exemption list at all. Nothing to move.

tests/tracker/reference-floor.ts and the min-reference-chars manifest entry now
name the GitHub tree's floor at its new site.

RED proof: shortening one justification in each registry to a token string
fails both first arms on the shared floor; probe reverted.

Refs #350
…d guard census

Deleted (2,703 lines):

  tests/tracker/containment.test.ts        the baseline oracle; its five live
                                           describes were re-homed in the
                                           previous commit
  tests/fixtures/containment-exemptions.ts 75 exemptions, every one addressing a
                                           line range of a baseline fixture
  tests/fixtures/tracker/baseline/         byte copies of git-agent.md, SKILL.md
                                           and github-api.md at 101bda7
  tests/guards/guard-census.test.ts        the git-agent.test.ts guard COUNT

The oracle answered one question — "did the split lose a line?" — against a tree
the branch no longer resembles. Two of its seven describes read the baseline; the
other five read dist/ and have moved. The exemption table is meaningless without
the line numbers it addresses, so the two go together (ADR-003).

Same-commit ripple, all of it:

  - `git-agent-guard-count` RETIRED from tests/fixtures/numeric-floors.json (30
    floors -> 29). The ratchet's own rationale was that the assertion could not
    live in the file it counts; with the census gone the floor has no site to be
    spelled at, and numeric-floor-manifest.test.ts would fail on the dangling
    sourceFile. A count of `it(` declarations was always a proxy for the guard
    surface (PF-064) — what replaces it is the roster claim below, which names
    the property instead of a number.

  - The census's SECOND claim survives, in the file it is about:
    `PHASE0_OPERATION_NAMES` -> `GIT_OPERATION_ROSTER` in tests/git-agent.test.ts,
    with both `it()`s kept verbatim. The 18 names and the `collectOperationNames`
    collector move with it. Registry Guard 6 stays green across a coordinated
    rename, so this is still the only thing that would see one.

  - Three known-bad probes read the baseline tree for their RED evidence. Each is
    re-homed, none is dropped, and each still drives the SAME collector the live
    arm does (PF-018):

      git-agent.test.ts D11 inline-body — now UNSCRUBBED_POSTING_SAMPLES, the 18
        posting recipes from that tree quoted byte-exact, asserted one-by-one
        rather than as a per-file count. Verified red by breaking one
        INLINE_BODY_SHAPES row.
      git-agent.test.ts GAP-25 `sleep 60` — now RATE_LIMIT_SLEEP_SAMPLES, the
        three sites, plus a discrimination arm on a literal the corpus lacks.
      git-agent.test.ts cross-cutting detectors — now
        CROSS_CUTTING_DETECTOR_SAMPLE, a seeded always-loaded header, and the
        probe additionally asserts the hits are LOCATED in more than one section
        and that the `## Operation:` body is left alone.

  - tests/seams/command-agent-input.test.ts's Handoff Values probe no longer
    needs a snapshot at all: it strips the three producer lines from a COPY of
    the live git.md body and asserts the same six pairs. Self-maintaining, and a
    strictly better probe than the one it replaces.

  - tests/guards/no-control-bytes.test.ts:263 named "the guard-census shape" for
    a pattern that now has no file to point at.

Net: the suite keeps every prohibition and loses only the corpus that could
answer questions about a tree that no longer exists.

Refs #350
End-state prose, no wave coordinates (ADR-003). No behaviour, no value and no
assertion changes anywhere in this commit.

tests/fixtures/numeric-floors.json — 16 `description` fields rewritten:

  dist-host-count, partial-count, dist-files-count, slow-test-timeout-ms,
  d11-posting-ops, agent-roster-count, issue-capture-contract-size,
  containment-issue-body-floor, containment-external-thread-floor,
  generated-reference-manifest-size, packed-reference-manifest-size,
  capability-hoist-block-floor, min-hostile-refs, budget-git-md,
  budget-loaded-set-jira, budget-loaded-set-linear

  Each said WHEN it was raised and by which commit of which wave ("RAISED 29 ->
  39 in the Phase-3 3b commit", "P2-S9", "c7bff85", "on main") rather than what
  the number IS. They now derive the value from the thing it counts — 34 = ten
  ops per provider plus three cross-cutting documents plus the contract; 49 =
  git.md's eighteen plus ten per provider; 6 = three original keys plus the three
  Handoff Values — which is the form that survives the next raise. `§14.x`
  tokens are gone.

  `floor` / `ceiling` / `pattern` / `occurrences` / `sourceFile` are byte-
  identical for all 37 entries, verified field-by-field against HEAD. The
  budget-* ceilings keep their P2/P3 pairing prose: that pairing is still true
  today and the collapse is a separate, separately authorised change.

tests/guards/retired-wording.test.ts — the `phase` field is dropped from
  RetiredEntry, from all 25 rows and from the failure message; the per-wave group
  banners become what each group IS (scoped literals, retired DEGRADED synonyms,
  retired status headings). EVERY entry is kept — the denylist never shrinks —
  and the `pattern?: RegExp` field stays: the phase-label class rule is the one
  entry whose members cannot be enumerated, and it is the reason a pattern exists
  at all. The header's per-wave inventory goes: `removedFrom` and `justification`
  already carry each literal's provenance per row, which is where it belongs.

tests/tracker/reference-structure.test.ts — PF-063's remedy stated as two halves
  that both hold (demote on the move; assert the prohibition) rather than as one
  half a past phase shipped and one it did not. The dangling
  `CONTAINMENT_EXEMPTIONS` reference goes with it.

tests/helpers.ts — the extractor's proof-obligation note claimed the generated
  references were frozen by "the containment oracle's 101bda7 baselines". They
  are pinned by their committed `.mds` sources and a deterministic build. Only
  that sentence is touched; collectTrackerNamingLines is not rescoped.

Refs #350
…and the ADR/PF/D4 restatements

Pure cuts to the always-loaded agent. Nothing gains a rule; seven restatements
lose one, and every rule they restated is stated in full somewhere the spawn
already reads.

- The Operations index loses its third column. Eighteen parameter lists, each a
  second copy of the operation's own `**Input:**` line two screens below it, free
  to drift against the authority they duplicate.
- `**Neutral values**` drops the ADR anchor and keeps the rule it annotated.
- The frontmatter-mismatch bullet drops the tail arguing why tracker.md survives
  uninstall. That argument belongs to the uninstall classification and is
  recorded at the code site that makes it; the agent needs the invariant.
- Both summary ops' publication step 2 becomes a pointer to the gate they already
  name. Step 3 - the fail-closed visibility probe - stays spelled inline in both,
  because a containment control is never a file the spawn might not have loaded
  (PF-058).
- Principles 1 and 2 stop paraphrasing the D4 block that sits 860 lines above
  them; principle 7 drops its pitfall anchor. Eight principles, unchanged count.
- post-resolution-summary step 4 drops a delivery coordinate for a phase
  numbering that no longer exists.

Measured: git.md 58,818 -> 56,779 ch (-2,039). Preamble 6,781 -> 6,566;
non-preamble 52,037 -> 50,213 against the unraised allowance of 52,365.
Loaded set: github 80,873 -> 78,834, jira 88,633 -> 86,594, linear 90,961 ->
88,922. Jira headroom 27 -> 2,066 ch, which is what funds the rest of this phase.

Guard literals classified (applies ADR-025), 1 file, 1 pin:
  RE-POINTED 1 - git-agent.test.ts's D10 three-value pin. The enum was spelled
  in both op sections AND byte-identically in references/publication-gate.md;
  it is now asserted once, in the reference, plus a new arm per op that the op
  still names the gate. Strictly more sites asserted than before.
  KEEP-EXACT 0, SOFTEN 0, STRENGTHEN 0.

retired-wording gains four entries: `ADR-007`, `PF-003` (both scoped to the two
files carrying the prompt - they stay legitimate in CLAUDE.md, docs/, the other
agents and the ledger), `Phase 5/9` and `Key Parameters`.

Expected red from this commit until the golden re-capture:
tests/goldens/git-agent-golden.test.ts byte-equality, by design.

Refs #350
…ub; nothing else names it

The tool-call contract carries the transport prohibition, the capability-selection
rule and the trust discipline for every tracker call a non-github spawn makes. It
was named by its consumers: five of the ten per-operation mechanics files. The
other five - fetch-issue, fetch-issues-batch, ensure-pr-ready, create-release,
gather-release-evidence - ran tracker calls with none of it.

An extraction that turns a universal obligation into per-consumer opt-in is the
defect, not the saving (PF-058), and "some shipped file names it" could never have
caught it: five namers satisfy that as completely as ten do.

The preamble now names it, on the SAME physical line that composes the
per-operation mechanics path. That keeps PF-023's single convergence point at
exactly one line, and it is sound rather than a loophole: what PF-023 counts is
where a path is BUILT, and this one is a fixed literal built from nothing - the
validated provider token selects the mechanics directory and never reaches this
name. `_mcp.mds`'s preamble, which argued the opposite, is rewritten in the same
commit rather than left self-contradicting.

`posting_gate_head` and both setup-task Setup bullets now invoke the contract by
name in prose instead of composing its path; ten generated op files lose the path
and none gains one. `aggregate_call_budget` drops its `[DR-09]` anchor - an
internal label in text a model reads.

Guard literals classified (applies ADR-025), 4 files, 6 pins:
  STRENGTHEN 2 - jira-module / linear-module "every posting mechanic invokes the
    tool-call contract". Each was one claim (the file names the path); each is now
    two (it invokes the contract by name AND does not compose its path).
  RE-POINTED 3 - reference-reachability's contract rule, from "a shipped consumer
    names it" to "the preamble names it", plus the two reachability set-builders
    that read it. The old rule was structurally incapable of seeing the defect.
  KEEP-EXACT 1 - provider-literals' aggregate_call_budget row, re-pinned exact on
    the new spelling.
  SOFTEN 0.

New arms: byte-budget splits the single-naming-line claim into composition (the
line builds the mechanics path from the provider token) and fixed literal (the
contract is named on that same line); reference-reachability gains the inverse
prohibition - no generated op file names the contract - with a discriminating
probe. budget-model declares MODEL_CROSS_CUTTING_ASSERTED beside
MODEL_CROSS_CUTTING_ON_DEMAND so the scope check sees both halves while the
recorded-cost table keeps counting only the glossary.

Measured: git.md 56,779 -> 56,987 ch (+208). Loaded set: github 78,834 -> 79,042,
jira 86,594 -> 86,782 (headroom 1,878), linear 88,922 -> 89,110 (headroom 1,890).

Expected red: tests/goldens/git-agent-golden.test.ts byte-equality, by design.

Refs #350
…ad-both, mismatch DEGRADED split by cause

Three defects in the provider-resolution preamble, one commit, because each fix
is unsound without the others.

ONE ALPHABET. A project key was shape-gated in three places that never see each
other, and two of them disagreed with the third: the Git preamble and the Tracker
agent's schema table admitted `^[A-Za-z][A-Za-z0-9_]{0,9}$` while every tool-call
provider's `KEY-N` grammar required `^[A-Z][A-Z0-9_]{1,9}$`. A key the preamble
resolved and the writer recorded could be one that no reference the agent then
rendered would accept, and the user sees that as an unparseable ref rather than as
a bad key. Both sites now state the provider grammar's own alphabet, with the
ASCII-upper normalisation happening ONCE at the key's own boundary. That is a
behaviour change: a lowercase key is now rejected at the gate rather than quietly
carried to a reference that refuses it.

READ BOTH RUNGS. "First hit wins" was wrong for rung 1 by construction: the
per-repo `tracker` key NARROWS the manifest's provider, so it cannot be evaluated
without reading rung 2 first. The order is otherwise unchanged.

SPLIT BY CAUSE. One DEGRADED spelling covered two different mistakes in two
different files with two different remedies - a per-repo override that narrows to
a provider the manifest does not carry, and a configuration file whose frontmatter
provider disagrees with the resolved one. A user who reads the unsplit reason
cannot tell which file to edit, which is what naming a reason is for.

The fourth project-key rung, "the documented neutral default", is deleted: it was
defined nowhere, and the absent-key outcome the schema table already documents is
`tracker not configured`. `$ARGUMENTS` - slash-command vocabulary in an agent
prompt - becomes "the task inputs".

LIVE_REASONS 18 -> 21 net so far (+2 -1 here): adds
`tracker configuration mismatch (repository override)` and
`(conventions file)`, retires the unsplit `tracker configuration mismatch`.
`PRE_PHASE3_REASONS` -> `GITHUB_ONLY_REASONS`: the list holds github-op wording,
which is a scope, not a date.

Guard literals classified (applies ADR-025), 4 files, 5 pins:
  STRENGTHEN 2 - `collectDegradedReasons` now balances one level of nesting. Its
    existing alternation was written for exactly this and could never fire: the
    leading `[^)]*` admits `(`, so a split reason came back truncated at the inner
    parenthesis and was reported as an unregistered spelling of a registered row.
    The provider-sources seam's MISMATCH_REASON moves onto the per-repo half, with
    a new arm proving the conventions-file half does NOT satisfy that rung.
  SOFTEN 1 (scope only) - hostile-values' "no normalisation pipeline of its own"
    now excludes the project-key schema row. Its subject is the PROVIDER TOKEN,
    which arrives validated and is copied verbatim; the key is a different value
    with a different provenance. The arm asserts the row it excludes exists, and
    a seeded provider-token pipeline is still reported.
  KEEP-EXACT 2 - hostile-values' laxened-validator probe, re-pinned exact on the
    new alphabet and gaining the lowercase-rejected / uppercase-admitted pair.

New arm: single-authority gains the one-alphabet claim over all three readers, by
extraction from each shipping file rather than against a constant here - a
constant in a test would be a fourth authority and the one nobody ships.

Measured: git.md 56,987 -> 57,482 ch (+495). Loaded set: jira 86,782 -> 87,277
(headroom 1,383), linear 89,110 -> 89,605 (headroom 1,395), github 79,042 ->
79,537 (headroom 1,407). Non-preamble unchanged; the growth is all preamble.

Expected red: tests/goldens/git-agent-golden.test.ts byte-equality, by design.

Refs #350
…bition

Blocker 1. `gather-release-evidence` parsed `#[0-9]+` out of commit messages and
then kept "only digit-only entries" - GitHub's issue grammar, stated in the
provider-NEUTRAL body of the agent. Under jira or linear it matches nothing, so
`SHIPPED_ISSUES` came back empty on every release and the whole
`backlink-shipped-issues` path plus both providers' `create-release` closed-issues
mechanics were dead by construction. Nothing reported it, because an empty
evidence set looks exactly like a release that closed no issues.

The step splits along the line the rest of this surface already uses. The agent
extracts CANDIDATES - tokenise on whitespace, keep what follows `refs`/`closes`/
`fixes` on the same line, bounded at 200 tokens (M7) inside the existing
100-commit bound - and states no grammar at all. The resolved provider's Mechanics
own what a reference is, and each now says so: `#[0-9]+` for github, the project
form for jira, the TEAM-KEY form only for linear (an internal id carries no team,
so nothing distinguishes one workspace's from another's). A `KEY-N` provider
additionally requires KEY to equal the resolved project key; a well-formed
reference on another project is `foreign issue reference {ref}`, not a shipped
issue. Dedup moves AFTER the grammar gate, where it was before the gate and could
therefore collapse two different projects' references into one.

Also stated, because the rewrite must not silently re-acquire them: a
`Merge pull request` subject and a trailing parenthesised reference carry no
keyword and were never candidates; an empty `SHIPPED_ISSUES` is empty, not
degraded.

The corroboration scan is replaced by the OD-9 prohibition. It named no command,
left "closing refs" undefined, gave a 60% share no denominator, and could not tell
jira from linear with a `KEY-N` grammar both providers speak - so what shipped was
an unexecutable rung. What survives is the part that is load-bearing and stated as
a prohibition: the remote, the hosting platform and the PR host are NEVER tracker
signals. Pull requests stay on GitHub under every provider, so a rule that reads
the remote would resolve `github` for exactly the users this feature is for.
Branch names go with it as a reference source in both provider modules.

New guard: `collectGithubGrammarInNeutralBody` in provider-scope. The existing
scan is one-directional - it looks for a FOREIGN provider's literal in a file that
is not that provider's - and by construction it cannot see the incumbent's grammar
left behind in neutral text, because `#[0-9]+` names no provider (avoids PF-072).
The new arm scopes to the documents that are provider-neutral by contract (the
agent and the tool-call contract), allowlists PR-host lines because pull requests
really do stay on GitHub, and was verified RED against this commit's parent on
exactly the two lines rewritten here.

Measured: git.md 57,482 -> 57,962 ch (OD-9 -737, release evidence +475 net of the
line it replaces). Loaded set: jira 87,757 (headroom 903), linear 90,085
(headroom 915), github 80,017 (headroom 927).

Deviation from the plan's ledger, recorded: the OD-9 cut measured -737 against a
budgeted -595, and the release-evidence rewrite +475 against a budgeted +310. Six
normative clauses do not fit in +310; the larger cut pays for most of the
overrun, and the two-server subsection in a later commit is written to the
remainder rather than to its own budgeted +900.

Expected red: tests/goldens/git-agent-golden.test.ts, and now also
github-status-lines (fixture line 165 - a sampled region) and the --unfreeze
round-trip that compares against it. All three by design until the recapture.

Refs #350
…freeze is retired

The always-loaded reader block said every rendered issue ref takes
`## Reference Rendering`'s form and is "never `#`-prefixed" - while nine Output
templates two hundred lines below spelled `#{number}`. The rule had to be a
PROHIBITION because the templates were frozen byte-for-byte by the Phase-0 golden
capture and the `#` could not be edited out, so the only available fix was to
reclassify a character as "github's rendering, not a literal" and hope a spawn
read the reclassification before it read the template.

The recapture authorised for this wave makes the bytes editable, so the templates
carry the token instead: nine issue slots become `{ISSUE_REF}` and the rule states
the POSITIVE github rendering, which the prohibition could never supply.

The eight PR slots are deliberately untouched. Pull requests stay on the PR host
under every provider, so `- **PR**: #{number}` is correct provider-neutral text,
and a rewrite that swept them along would render a PR reference no host resolves.
That discrimination is now asserted in both directions rather than left to care.

The command layer's capture contract and the github batch mechanics follow the
heading they read.

Decision recorded: `tech-debt archive failed for #${old_issue}` in the github
manage-debt mechanics is EXCLUDED. It is a shell expansion inside an executable
`||` chain in a file that only runs under github, where `#N` is the correct
rendering; `{ISSUE_REF}` is a token the agent substitutes into an Output template,
and substituting it into a shell recipe replaces a live variable with a literal
brace pair. The exclusion is written at the registry entry, not only here.

Guard literals classified (applies ADR-025), 2 files, 5 pins:
  RE-POINTED 2 - schema-scope claim 6 clause 2, from the prohibition
    (`never `#`-prefixed`) to the positive github rendering; and the
    command-agent-input seam's ISSUE_REF producerPattern, which keyed on the `#`.
  DELETED 2 - claim 6 clause 3 (the reclassification rule, retired with the freeze
    that forced it) and its companion arm, which asserted the templates STILL
    carried the `#` and would otherwise now assert the opposite of the fix.
  STRENGTHEN 1 - that companion is replaced rather than dropped: every issue slot
    renders through the token, no issue slot spells the bare `#`, and the PR slots
    are asserted to keep theirs.

Measured: git.md 57,962 -> 57,976 ch (+14). Loaded set: jira 87,771 (headroom
889), linear 90,099 (headroom 901), github 80,031 (headroom 913).

Expected red: the two goldens and the --unfreeze round-trip, by design.

Refs #350
…e named-4xx signal once

Two rate-limit holes, one on each path.

GITHUB REGRESSION. `resolve-review-threads` fans out over up to 50 threads and
lost its rate-limit rungs when they moved into `references/github-api.md` - a file
this operation never loads. Its D4 clause covered "other 4xx" and 5xx and said
nothing about backpressure, so the op's own text left the fan-out with no stop
condition. It now points at the rungs, by the same route `fetch-review-threads`
already uses. The rungs themselves are NOT restated: the always-loaded agent must
not name GitHub's header, because two of three providers publish no such count,
and `provider-literals` holds that line.

TOOL-CALL PROVIDERS. Linear's backpressure is an HTTP `400` carrying
`RATELIMITED`, which a status-shaped detector classifies as a generic 4xx - and
D4 answers a generic 4xx with "degrade this item and continue", so the fan-out
runs straight into the penalty window. The correction was stated in exactly one
place: `backlink-shipped-issues`. Every other Linear operation - every
single-call one included - applied the generic rule.

The tool-call contract now carries `### Rate-limit signals`: a named error inside
an ordinary 4xx is D4's STOP rung wherever the resolved provider's mechanics name
one, read from the error TEXT rather than the status, binding on every operation
and not only the one that fans out. It is read once per spawn under every
non-github provider, which is what makes it reach the other nine. The provider
file keeps what is genuinely its own - the literal, the status code and the
transport form - and drops the reasoning the contract now states once.

budget-model gains `resolve-review-threads: ['github-api.md']`, so the
bidirectional file-set check stays honest about a reference the op now names. The
worst-case term does not move: the non-tracker maximum was already that file.

Measured: git.md 57,976 -> 58,100 ch (+124). _mcp.md 6,399 -> 6,907 (+508).
max_op linear 7,636 -> 7,479 (-157). Loaded set: jira 88,403 (headroom 257),
linear 90,574 (headroom 426), github 80,155 (headroom 789).

Budget warning, recorded rather than absorbed: at 257 ch of jira headroom the
remaining Part B additions do not all fit. See the phase handoff - the
two-server subsection is the item that does not, and it is reported rather than
compressed below the content its acceptance criterion names.

Expected red: the two goldens and the --unfreeze round-trip, by design.

Refs #350
…d an end to the permanent DEGRADED

Blocker 4. `## Reference Rendering` had no probe, no documented default and no way
to be filled. The Tracker agent wrote `# UNRESOLVED:` into it because that is what
its schema says to write when it looked and could not tell; the Git agent reads
that sentinel as `tracker.md required fields incomplete`. So `ensure-pr-ready` and
`create-release` degraded on every run, for every jira and linear user, over a
field that was never going to resolve - and the remedy the reason names ("edit
~/.devflow/tracker.md") pointed at a section with nothing to put in it.

The fix is a split, and the split is forced by `provider-scope`. The RULE is
provider-independent and lives in the contract's rendering gate: an absent
section, an absent file and a discarded token are the SAME outcome - the resolved
provider's documented default - and this gate never yields `# UNRESOLVED:`. The
VALUE cannot live there: a provider-keyed table inside `_mcp.md` would put `jira`
and `linear` literals in a file that guard scans and no provider owns. So each
provider states its own, once, as a define beside the comment cap - the other
provider fact that is stated once and invoked everywhere - and renders it at each
gate site: `Refs {KEY}-{n}` for jira, `Refs {REF}-{n}` for linear.

The lookup key is "the resolved provider", in those words. "This file's provider"
would let a hand-edited `provider:` frontmatter route around the mismatch guard
and pick the rendering of a tracker nobody resolved.

The writer moves with the reader, in the same commit, because the two halves are
only correct together: the Tracker agent's schema row now says never to write
`# UNRESOLVED:` there. A sentinel means "the writer looked and could not tell",
and for this row the honest answer is a value the reader already has.

Guard literals classified (applies ADR-025), 1 file, 1 pin:
  KEEP-EXACT 1 - jira-module's MODULE_DEFINES gains `pr_link_default`, owned by
    the two tool-call providers exactly as `comment_cap` is, with a body shape.
    Without the row the parity scan reads a deliberate two-provider ownership as
    an asymmetry; with it, a third provider omitting the define is still reported.

New arms in single-authority: the rule is authored ONCE in the define that
expands into every gate site and is keyed on the resolved provider; each provider
states exactly one default and the emitted contract states none; a discarded
token - `pr-link: $(whoami)`, denied by the gate's own `$` rule - routes to the
default and a `### Substitutions` row and never to the incomplete-fields
degradation; and the writer's row carries the matching prohibition.

Measured: no gated term moves. The gate define is authoring-only, so `_mcp.md`
stays 6,907 ch, and the two sites that gained the value - create-release and
ensure-pr-ready - are neither the max_op term nor the worst-case op. Loaded set
unchanged: jira 88,403 (headroom 257), linear 90,574 (headroom 426).

Scope deviation, recorded: the two-server per-capability scoping subsection
planned for this commit is NOT here. It is a `_mcp.md` addition, billed per spawn
to both providers, and cannot be written in 257 ch without cutting the clauses its
acceptance criterion names. Reported with the arithmetic in the phase handoff
rather than compressed or funded by raising the ceiling.

Expected red: the two goldens and the --unfreeze round-trip, by design.

Refs #350
…osts none of it

Gate-0 decision. On a provider whose comment format has no collapsed-block
analogue, the design artifact degraded to a POINTER: a comment naming
`{PLAN_ARTIFACT_PATH}`, a local file that is not committed. That resolves for its
author and for nobody else, so the traceability comment - which exists for the
reader who was not in the room - handed them a filename. The plan is now the
comment: posted through the same gate as every other body, line 1 the marker,
referenced from `## Implementation Plan`.

The over-cap branch is the part that needed stating, because truncating is the
obvious thing to do with a body that is too long and it is the wrong thing here.
A truncated plan reads as a whole plan; nothing in the comment tells the reader
which half is missing. So over the cap the operation posts NONE of the plan,
falls back to the pointer sentence, and emits
`TRACEABILITY: DEGRADED (plan artifact exceeds comment cap)` - no `{provider}`
token, because the cap is the provider's and the failure is not. The cap is
measured AFTER redaction: the scrubber's replacement tokens can make a body that
fitted before the scrub too long after it.

LIVE_REASONS 19 -> 20 with the new row, registered in the commit that authored its
emitting site.

New arms in single-authority: per provider, the artifact section states content
rather than a pointer, both the old heading and the sentence that argued for it
are gone, and the over-cap branch refuses rather than truncates - with a probe
that discriminates the two shapes, since the truncation form is CORRECT for the
D3 comment sitting three lines below it.

Measured: no gated term moves. `ensure-traceable-issue` is neither the max_op term
nor the worst-case op for either provider. Loaded set unchanged: jira 88,403
(headroom 257), linear 90,574 (headroom 426), github 80,155 (headroom 789).

Order deviation, recorded: this is the plan's ninth P3a commit, landed eighth. It
has no dependency on the `ref_preflight_head` hoist that was to precede it, and
the hoist rewrites emitted text at both gated terms while 257 ch of headroom is
left - so the fix with user-visible behaviour went first rather than being put
behind a refactor that may not fit.

Expected red: the two goldens and the --unfreeze round-trip, by design.

Refs #350
…ladder as shared rules

Two of the eight authoring-only defines in the tool-call contract had no
SHARED_RULES row, so the guard that says "declared in the authoring module and in
no provider module" was silent about them. Both are rules a per-op file would
plausibly restate, and both decide something a second copy would decide
differently:

- `reference_rendering_gate` carries the shape gate, the metachar denylist and
  the fallback a discarded token routes to. The token is interpolated into a
  branch name and into a PR body from a hand-editable machine-wide file, so a
  second author is a second denylist.
- `dedup_ladder` carries the four rungs and the rule that a recorded hint may only
  NARROW the probe order, never raise it. The rung a provider lands on decides
  whether a release back-link is suppressed on evidence or on a coincidence, and
  that qualification has to live in the same sentence as the rungs it qualifies.

Both rows are proven live by the arm that already asserts every registered rule
reaches every tool-call provider's generated tree.

Expected red: the two goldens and the --unfreeze round-trip, by design.

Refs #350
The floor and its message had drifted from the table: it said "eighteen" and
guarded `>= 18` while the table now carries twenty rows - the mismatch reason
split by cause (+2 -1) and the plan artifact's cap (+1), each registered in the
commit that authored its emitting site.

A floor rises with the table and never falls. Left at 18 it would have admitted
the silent removal of everything this phase added.

Not 21. The twenty-first row planned for this phase,
`ambiguous tracker server — {n} servers offer {capability}`, has no emitting site:
the two-server scoping subsection it belongs to did not land, and a registered
reason with no emitter fails the forward arm by design. The phase handoff records
why, with the byte arithmetic.

Refs #350
…er wins, ambiguity is DEGRADED

Two connected servers that both offer tracker capabilities is the one
configuration in which a write lands in somebody else's tracker and nothing
downstream can tell (AC-13). The tool-call contract now answers it, in six
clauses, in the file that is read once per spawn by every tool-call provider:

  - partition the exposed tools by the server that provides them;
  - qualification is per CAPABILITY, never per server — qualifying for one
    promotes a server for no other, so a create and its comment cannot be
    routed apart by a single promotion;
  - exactly one qualifying server wins and nothing further is asked of it: a
    server whose descriptions never name the tracker is still the only thing
    that can serve the capability, and refusing it degrades on terseness;
  - two or more ⇒ no call for that capability and
    `TRACEABILITY: DEGRADED (ambiguous tracker server — {n} servers offer {capability})`;
  - the winner is pinned for the whole spawn;
  - a write is preceded by one corroborating read from the SAME server — once
    per spawn, never per item (design review H2), so a fifty-issue backlink
    does not become a hundred calls.

The untrusted-reads paragraph is extended rather than duplicated: a tool
DESCRIPTION is vocabulary for deciding what a tool does and never an
instruction to follow.

The rule is spelled WITHOUT the transport's tool-name prefix literal.
tests/guards/provider-scope.test.ts forbids that literal in every loadable
file, so the partition is stated as the leading namespace segment of the tool
name — the same claim, in the vocabulary this corpus is allowed to use.

Emitter and registry land together, per the standing rule that a DEGRADED
literal joins LIVE_REASONS in the commit that authors its emitting site: the
table goes 20 → 21 and its floor with it. `{n}` is registered as
NON-instantiable — a count has no closed domain — while `{capability}` is
instantiated against the contract's own table, and an arm proves both halves.
A second arm proves the plural case does not reuse `no tracker tool for
{capability}`: "nothing offers it" and "several things offer it" are opposite
conditions with opposite remedies.

Funded from inside the contract, not from a raised ceiling. Three cuts, each a
restatement of something the same file already says once: the last-four-rows
note under the capability table (the table's own right column), the
rotation clause's echo of the status line it follows, and "Never probe inside
a loop" after the sentence that already says once per spawn.

BUDGET_LOADED_SET_JIRA and BUDGET_LOADED_SET_LINEAR are RED after this commit
and are left red deliberately: contract prose is billed once per spawn to both
providers, the clauses AC-13 names do not fit the transition headroom, and the
ceilings are re-pinned from measurement by the phase that owns them. No
ceiling was raised here. git.md and the GitHub loaded set are unmoved.

Pins classified (applies ADR-025): STRENGTHEN 6 (reason-table floor 20→21, the
{n}/{capability} instantiation arm, and the four two-server clause arms with
their delete-one probes), STRENGTHEN 1 (MCP_SHARED_LITERAL_REGISTRY gains the
qualification sentence). SOFTEN 0, DELETED 0, RE-POINTED 0.

Refs #350
… lines into _common.mds

Fourteen ref-grammar pre-flight sites across `_jira.mds` and `_linear.mds`, and
nine lines that all three tracker modules wrote out identically, now have one
author each. New partial `src/assets/mds/tracker/_common.mds` holds:

  - `compliance_step_gate` / `compliance_issue_policy` — every provider's,
    the CLI one included;
  - `state_batch_line(subject, subject_ref, ref_token)` — the `**State**:` batch
    rendering line, parameterised by the three words that differed;
  - `bare_number_rule` and the four ref pre-flight heads — `single`, `list`,
    `entry`, `branch` — the tool-call providers', with the grammar, the
    normalisation step, the alternation warning and the per-site tail arriving
    as arguments.

THE PRE-FLIGHT HEADS ARE NOT IN `_mcp.mds`, WHERE THEIR SUBJECT PUTS THEM, AND
THE REASON IS MEASURED. Compiling `_jira.mds` against `_mcp.mds` costs 3.3 s at
nine defines there, 4.9 s at ten, 8.3 s at eleven, and does not finish inside
twelve seconds at twelve — measured with defines whose entire body is one
character, so it is the define COUNT against that module's size and not the
content. The same five defines cost 1.2 s in `_common.mds`. The ownership split
is therefore stated as: `_mcp.mds` owns the emitted contract and the defines the
sink-bypass guard requires every posting mechanic to spell; `_common.mds` owns
every other shared line, with each define naming its own audience. Both module
headers say so, and the cliff is recorded at `_mcp.mds` so the next hoist does
not rediscover it by hanging a build.

FOUR DEFINES, NOT ONE TAKING A MODE, for a second mechanical reason: MDS `@if`
is block-structured and its expansion ends the line, while all fourteen call
sites are fragments in the middle of a markdown list item. A mode parameter
would have broken the list each site lives in.

The compliance and `**State**:` hoists are emitted BYTE-IDENTICALLY — the whole
generated reference tree was diffed against its pre-hoist copy and only the
fourteen pre-flight sites moved. The pre-flight wording is unified where the
hoist merged two spellings of one rule; there are no jira or linear goldens, and
the two gated terms were held: jira's `setup-task` and `backlink-shipped-issues`
are byte-unchanged, linear's max_op moved +1 and its worst-case op +11.

One line is DELIBERATELY not hoisted and now says so in a test rather than in a
comment: the marker-neutralisation bullet is byte-identical in content and NOT in
indentation — five spaces in the GitHub module, three in the other two, because
the list nests differently. One define emits one string, so hoisting it would
re-indent one of the three, and a bullet re-indented out of its parent stops
qualifying the step it belongs to (PF-063).

`tests/fixtures/mds-manifest.ts` is touched exactly once, here, as the plan's
fourth sequencing constraint requires: partial discovery is widened from a
listing of `_partials/` to a repo-wide walk classifying by the build's own rule
(no `output-dir:` key), `MDS_REFERENCE_PARTIALS` and `ALL_MDS_PARTIALS` join the
manifest, and `partial-count` goes 12 → 13 in the same commit — a count-only
bump would have gone red naming no file.

`dist-host-count`'s pattern is narrowed to name its receiver in the same commit,
as a forced ripple: `partial-count` now pins the same number in the same file,
and two entries sharing one bare pattern have no per-entry accounting — each
one's decrement probe would keep finding the other's site.

Pins classified (applies ADR-025): STRENGTHEN 12 (repo-wide partial set-equality
and its probe, the output-dir arm widened to every manifest partial, both floor
patterns narrowed to their receivers, four SHARED_RULES rows for the hoisted
heads, the declaration arm now checked in BOTH directions against BOTH authoring
modules, the import arm against both, and the three containment-indent arms),
RE-POINTED 3 (the `_partials/` floor to the repo-wide roster, the printed-count
expectation, the tarball shipped-source count). SOFTEN 0, DELETED 0.

Still red by design and only these: the three goldens, and
BUDGET_LOADED_SET_JIRA / BUDGET_LOADED_SET_LINEAR from the preceding commit.

Refs #350
…olds

The first line called the module "the provider-NEUTRAL lines every tracker module
writes identically", which the body then contradicts three paragraphs later: five
of its eight defines are the TOOL-CALL providers' only, and they are there for a
measured compile reason rather than because they are neutral. A module header
that disagrees with its own module is the thing the next reader trusts first.

Module-level prose; the build emits it nowhere and no artifact byte moves.

Refs #350
…in, one heartbeat, positive path shape

The claim gate echoed nothing: `:` on the winning branch and `exit 0` on the
losing one are indistinguishable outputs, so a run could not classify its own
outcome and neither could anything reading it. It now echoes CLAIMED / LOST and
exits 3 on the loss, with "absent output ⇒ LOST" as the fail-closed default for
a run killed between the create and its echo. The claim PRIMITIVE is unchanged —
`set -o noclobber` is already exclusive (PF-068); this commit is observability.

The compose step was an unchecked first link: `cat > "$RAW" <<'EOF' … EOF` is its
own statement and the shell discards its status, so a full disk or a vanished
$RAW produced an empty composition that every later link happily scrubbed. It is
brace-grouped and `&&`-joined into the same chain the placement hangs off, which
keeps the chain single and unbroken (PF-066).

The shape gate bracketed the composition at two anchors and said nothing about
the lines between or after them — which is where `### Substitutions` sits, and
every row of that section is a value that already failed its own shape gate. A
third, negated `grep` validates the whole range against the `## Reference
Rendering` denylist (backtick, `$`, `;`). Double quote and backslash are
deliberately NOT at this link and both reasons are stated in the prompt: the
scrubber may re-quote an assignment it redacted, and a backslash in a bracket
expression is an escape to some greps and a literal to others.

The heartbeat instructed a per-unit cadence (~19 touches). It is now ONE touch at
the probe → compose boundary: a cadence in a prompt has no observable count, so
nothing distinguishes a run that followed it from one that touched once, while
one refresh at the boundary re-arms the bound for the only phase that could
outlive it.

Also: every path is bound in the `## Environment` fence and referenced only as
"$VAR" (`TRACKER_ATTEMPTS_FILE` joins the three); a closed-enum global-safe
section whose default is one of its own values is never sentinelled (with
`## Dedup Strategy` explicitly carved out, its default being a live probe); the
release of the claim is stated for write-less exits too; and `[DR-02]`, `PF-003`
and the test-file path are dropped from the prompt — a model cannot follow an
anchor, and each rule they annotated is stated in place.

Pins classified per literal (ADR-025), counts per class per file:

  tests/tracker-agent.test.ts              KEEP-EXACT 5 · RE-POINTED 2 · STRENGTHEN 7
  tests/shell-hooks-tracker.test.ts        STRENGTHEN 1
  tests/seams/tracker-claim-staleness.ts   STRENGTHEN 1
  tests/guards/heredoc-quoting.test.ts     STRENGTHEN 2
  tests/guards/retired-wording.test.ts     STRENGTHEN 2

KEEP-EXACT are the literals bound to a shared constant or carrying the protocol:
the two schema-oracle grep anchors, `FINAL act`, and the two new outcome literals
(`Absent output ⇒ LOST`, `a write-less exit still deletes the claim`).
RE-POINTED are the two bounded-window regexes whose SUBJECT survived and whose
address moved (`[DR-02]` → the claim it cited; `.tracker.attempts` → the bound
variable) — same strength, new address. STRENGTHEN replaces prose with an
executed or counted check: the two-claim sequence becomes 8 concurrent claimants
(exactly one CLAIMED, every loser LOST with status 3, and rename-to-claim driven
through the same harness to prove it reports many winners); `/\btouch\b/` becomes
a counted collector that reports a repetition; the shape gate's two greps become
three named links; `collectScrubChain`'s statement splitter becomes quote-aware
(the range link quotes a bracket expression containing `;`, which the old
splitter would have cut the chain at — a false positive on a security control);
`breakScrubChain` now breaks the `&&` AFTER the scrubber rather than the first on
the line, which the compose join had made the wrong one; and the hook's
source-level [DR-10] arm now reports READS before the sentinel, not only forks.
No pin was widened to make a red test green.

The hook's path positive-shape control landed in Part A and is unchanged here;
this commit only verifies it adds no read ahead of the sentinel gate.

Refs #350
Two github assumptions sat on the path a non-github issue has to travel, one at
each end of /implement.

The setup-task spawn forwarded ISSUE_INPUT only when `$ARGUMENTS` started with
`#`. That is github's spelling; `PROJ-12` and `ENG-12` are the other two. Under
either of them the argument fell through to TASK_DESCRIPTION, so the branch was
derived from prose, no issue was fetched, and nothing reported a problem. The
first token is now forwarded verbatim and the only test the command makes is the
one that holds under every provider: a path ending in `.md` is a plan document.
`setup-task` is where a provider has been resolved, so classification belongs
there and nowhere earlier.

The PR step rendered `Closes #{ISSUE_NUMBER}` whenever a number was known, with
no provider in the condition — under jira or linear that number is the tail of a
key, so the line closed whichever GitHub issue carried the same digits: a wrong,
silent, GitHub-visible write. The body now uses `ISSUE_PR_LINK` verbatim when the
Git agent captured one (it is already rendered in the resolved grammar, and
re-rendering is a second rendering site), and composes the `Closes` line only
under github.

The Code agent's paste gate had a single github arm, so the one value this seam
exists to carry was rejected as malformed for two of the three providers. It is
now one full-line-anchored arm per provider, with the two bounds an anchor cannot
express written out (a newline is refused — `$` is end-of-LINE in some engines —
and the line is capped at 60 characters). `(none)` is explicitly not a mismatch;
a mismatch names the RESOLVED provider in the DEGRADED reason; and a bare number
under a non-github provider reuses the already-registered
`ambiguous issue reference` rather than minting a spelling. LIVE_REASONS is
unchanged at 21.

The jira and linear arms OVERLAP on a plain uppercase key — they part only on
jira's underscore and linear's single-character keys — which is stated in the
prompt and recorded in the payload table, and is the reason the resolved provider
picks the arm instead of the arms picking between themselves.

`provider-scope`'s block allowlist becomes a registry of allowlisted REGIONS
rather than one constant, with the paste gate as its second row and a
justification floor on every row. The alternative was a hand-written second
condition beside the first — two mechanisms for one rule, which is how one goes
stale (PF-067). The Code agent is outside the Git spawn surface and loads no
provider mechanics file it could defer to, so a per-provider sink check has to
enumerate the closed set once, inside the gate; it reads the arm for the provider
already resolved rather than resolving one, which is what keeps it a sink check
and not a second convergence point (PF-023).

Pins classified per literal (ADR-025), counts per class per file:

  tests/seams/pr-link-handoff.test.ts    RE-POINTED 2  (+ 6 new executed arms)
  tests/guards/provider-scope.test.ts    STRENGTHEN 2
  tests/guards/retired-wording.test.ts   STRENGTHEN 1
  tests/build-mds.test.ts                (3 new arms, no pin reclassified)

RE-POINTED are the two `does not match github reference grammar` pins, whose
subject survived and whose address moved to the `{provider}` template — same
strength, new address. The retired-wording addition is a CLASSIFIER, not a
spelling: the literal survives at exactly one site and the entry's pattern
admits it only while it stays github-gated, so the permitted occurrence is
registered rather than excluded by hand. The new pr-link arms execute the
grammars read OUT of the prompt against a payload table rather than re-typing
them, so a missing anchor is caught by running it instead of by reading it.
No pin was widened to make a red test green.

Refs #350
…rdinates from the hook

`references/github-api.md` stated the thread-resolution condition in its own
words, and the words were wrong: it admitted FALSE_POSITIVE and BY_DESIGN as
resolvable, where D9 says those two are the thread author's call to close and
devflow replies with cited evidence but leaves the thread unresolved. That is
what a second authority costs — not drift in the abstract, a live contradiction
between the document an operation loads and the gate the agent applies. The
reference now names the gate and holds the mutation.

A new guard keeps it that way: the resolve PREDICATE is stated exactly once
across the reference tree, by the marker registry that defines D9. The corpus is
the tree, not the agent prompt — the Git agent states the gate three ways (a
table, a sentence, the step that applies it) and those are one document's
internal business; what a reference cannot do is repeat a rule it has no way to
notice moving. The detector matches the predicate, not a byte-exact sentence,
because the defect it exists for WAS a paraphrase, and a registry of spellings
only catches the spelling somebody already wrote.

`GITHUB_API_MD_CHARS` is re-measured and re-pinned in this commit, 21,218 →
21,166, which is that constant's own rule: the only commit that may move it is
the one that edits the file's bytes.

The hook's two `(§14.9 constraint N)` comments are dropped. A hook comment is
read during a live debugging session by someone who has the script in front of
them and not the design artifact, so the coordinate is cost with no reader, while
the rule each annotated — reject never repair, and the closed model domain — is
stated in the same sentence. Registered in `retired-wording` as a pattern scoped
to the hook tree: the `src/core` JSDoc sites carry the same coordinates for a
maintainer reading the source with the repo checked out, which is a different
argument, and sweeping both under one entry would settle it by omission.

Pins classified per literal (ADR-025), counts per class per file:

  tests/guards/retired-wording.test.ts   STRENGTHEN 1
  tests/tracker/byte-budget.test.ts      1 equality baseline re-measured and re-pinned
  tests/git-agent.test.ts                (4 new arms, no pin reclassified)

Refs #350
…ired agent name

`Validator` is a retired agent name, and GAP-5's guard forbids it in any shipped
artifact — including as an ordinary English word. The new closed-enum rule
introduced one in B7. The schema table's own column is "Shape gate at the sink",
so naming the gate the way the table names it is both correct and the spelling
the rest of the section already uses.

Refs #350
…nd git-agent.md

Both goldens re-captured under the third — and final — one-time authorisation
(Gate 1, 2026-09-19). Measured first with
`scripts/update-golden.ts github-status-lines --unfreeze --out-dir <scratch>` and
the scratch output diffed against the live fixture before anything was written;
the fixtures are byte copies of that measurement, never hand-edited.

Nine fixture lines moved, each mapping to a named Part-B edit, and the line
count is unchanged (250) — no offset drift:

  48, 52, 61, 86, 99  the {ISSUE_REF} rendering rule and its template sites
                      (8322372)
  165, 166            the release-evidence candidate gate and grammar gate
                      (1ccaf45)
  239, 240            the provider-neutral PR link line in code.md (d9e71a3)

Equality baselines re-set to the measurement in the same commit as the bytes:
GIT_MD_CHARS 58,818 -> 58,100; TOTAL_CHARS 68,341 -> 67,623;
FIXTURE_BYTES 17,527 -> 18,270; GIT_AGENT_BYTES 59,279 -> 58,549.
GIT_MD_LINES, TOTAL_LINES, FIXTURE_NEWLINES, SKILL_* and every PRE_PHASE0_*
are unchanged. No assertion logic changed.

The header now records all three spends (e4876e0, c0b9860, this commit) and
states that the authorisation is spent and none is outstanding.

Refs #350
…ings onto one re-baselined number each

Gate 1 authorised a one-time re-baseline of the byte ceilings: set just above
the post-change measurement, record what the raise bought, retire the companion
gate, re-derive downward thereafter. Every number below is read off the byte
table this file prints, never hand-derived (PF-057).

  BUDGET_LOADED_SET        77_824 -> 80_200  (measured 80_155, headroom 45)
  BUDGET_LOADED_SET_JIRA   88_660 -> 89_500  (measured 89_459, headroom 41)
  BUDGET_LOADED_SET_LINEAR 91_000 -> 91_700  (measured 91_642, headroom 58)
  PREAMBLE_MAX_LINES           40 ->     36  (measured 35, headroom 1 — LOWERED)

BUDGET_LOADED_SET_P3 is deleted: the GitHub row is now one pinned number gating
the shipped shape rather than the frozen pre-split set plus a computed phase
companion. The two provider rows' "not a free number" companion arms are retired
with it — they held the delta over a GitHub ceiling that was itself derived, and
neither row is derived any more. What stops a provider ceiling being a free
number now is the thin headroom recorded in its JSDoc plus the downward-only
rule, both of which the surviving gate's message states.

What the provider raise bought is entirely references/tracker/_mcp.md,
6_907 -> 7_963 ch: two-server per-capability scoping, the rate-limit signals
section, the Reference Rendering rule, and the plan artifact posted as content
with an over-cap DEGRADED path. Each JSDoc and each numeric-floors.json
description records this, dated, and says "LOWERED THEREAFTER, NEVER RAISED
AGAIN" (PF-071 — a one-time re-baseline is recorded as one).

RED proved before the pin, then the probes removed: the collapsed GitHub ceiling
refuses the pre-B1 measurement (80,873 > 80,200); each provider ceiling refuses
its measurement plus 61 ch (89,520 > 89,500; 91,703 > 91,700); the preamble
ceiling refuses a 37-line preamble.

HELD, and NOT in this commit: the git.md pair. BUDGET_GIT_MD / BUDGET_GIT_MD_P3,
PREAMBLE_CHARS_P2, their arms and their numeric-floors.json values are
byte-unchanged — measured git.md is 58,100 against the plan's bounded collapse
target of 56,400, so the collapse is the plan owner's decision, not this
phase's. The two held descriptions lose only the sentences that named the now
deleted BUDGET_LOADED_SET_P3; their values and patterns are untouched.

Refs #350
…state

FROZEN_LIFECYCLE_RULE said the fixture was "frozen at Phase 0, never regenerated
through Phase 3", which read as a constraint that expires. It does not: the
fixture is frozen, and regenerating it takes --unfreeze AND a fresh explicit
authorisation naming the bytes it permits. Three have been granted and all three
are spent — the log lives in tests/goldens/github-status-lines.test.ts, which the
rule now names rather than restates.

No behaviour change: the refusal path, its exit code and the --unfreeze/--out-dir
parsing are untouched.

The refusal guard's pin moves with the wording, and is strengthened while it
moves. It matched /frozen at Phase 0|never regenerated through Phase 3/ over the
whole combined output; the replacement matches the rule sentence's own clause,
because the override hint printed two lines below it carries both "--unfreeze"
and "authorisation" and would satisfy any looser pattern on its own. Proved red
against a rule sentence missing the authorisation half, then green.

Refs #350
…end state

CLAUDE.md's single ~9.5 KB Tracker paragraph becomes three end-state
paragraphs — selection and bundle, how operations run, lifecycle — at
3,458 characters with no internal identifier in any of them. A citation a
reader of CLAUDE.md cannot follow is decoration, and a mis-numbered one is
worse than none.

"Universal Skill Installation" is retired as a description because the
install is no longer universal: skills install for the selected plugins
plus their declared `requires:`, the default plugin set carries 32 of the
40 plugin-owned skills, and a shadow outside the selection is dormant
rather than deleted. README, CHANGELOG (Unreleased), the CLI reference,
the skill catalog and the file-organization doc follow the same end state,
including the two user-visible behaviour changes this branch carries: Jira
issue keys are ASCII-upper-normalised, and the install footprint drops to
13 reference files for github against 24 for jira/linear.

Lines naming deleted files are removed rather than annotated: the
containment exemption registry, the baseline reference tree and the guard
census no longer exist, so the inventories no longer list them. Git holds
the history.

New guard tests/docs/claude-md-tracker-section.test.ts holds the block to
3,500 characters and to zero internal identifiers, with both collectors
driven against seeded known-bad input. The ceiling is registered in
numeric-floors.json so it can be lowered and never raised. RED proved
first: 5 of 6 assertions failed against the previous CLAUDE.md.

Refs #350
…ences and test-harness knowledge

Four knowledge bases rewritten as end state rather than annotated with a
transition. Phase labels, the "3b/3c adds" narration and every reference
to a file this branch deleted are gone; git holds the history.

New surface the four now describe: `requires:` per plugin with `skillsOf`
/ `skillOwners` / `buildScopedSkillsMap` / `resolveSkillInstallPlan` /
`TEMPLATE_SKILL_REFS`; `installedReferenceManifest` as the INSTALL-side
sibling of `generatedReferenceManifest` (13 for github, 24 for
jira/linear, `_mcp.md` iff MCP-backed) with the standing trap named —
the loaded set is per-spawn and is neither of the other two numbers;
`overlayInstalledReferences` as the one wrapper; `convergeTrackerArtifacts`
in the new `tracker-install.ts`, including why the Tracker agent is copied
and then removed on a github install; `install-report.ts` as the new home
of the summary renderers; `runTrackerSet` with its fixed converge order
and two exit-1 branches; `readTrackerMechanics`' three outcomes;
`resolveInstalledPlugins` and retention-from-manifest on uninstall.

Constraints recorded so nobody rediscovers them: `_mcp.mds` is at a
measured define cliff (12 defines do not finish compiling), so
`_common.mds` is where anything further hoists; `collectDegradedReasons`
is parenthesis-balanced, which makes a naive `\(([^)]*)\)` parse of a
DEGRADED reason wrong; `LIVE_REASONS` is 21 and `PRE_PHASE3_REASONS` is
now `GITHUB_ONLY_REASONS`; the golden fixture's three authorisations are
all spent; the ceilings are stated at their current values with the
one-time authorised re-baseline recorded as one.

`code.md` is named in tracker-feature as a THIRD authority for each
provider's reference grammar — accepted because the Code agent is outside
the Git spawn surface with no mechanics file to defer to, and recorded
with the condition under which those arms move.

Also in this commit: the one-line frozen-list note in legacy.ts. The
`*_V2` names are the frozen spelling of an era, not a version to bump
(avoids PF-012).

Refs #350
…ches

Clarity and de-duplication only; no behaviour change, no assertion or guard
arm weakened.

- src/cli/commands/init.ts: four copies of the "log each SummaryLine at its
  own severity" loop collapse onto one `logSummaryLines` helper. The helper
  keeps the `switch` + `const _exhaustive: never` arm the overlay-summary
  copy carried, so a level added to `SummaryLine` is still a compile error
  rather than a silent degrade to `info` at four call sites.
- src/core/mds-variants.ts: `contractRelPath` takes the already-expanded
  pairs instead of re-expanding the registry. `installedReferenceManifest`
  has already thrown on a registry that does not expand, so the second
  expansion was duplicated work behind a refusal branch that can never fire.
- src/targets/claude-code/tracker-install.ts: `firstExisting` reuses
  `pathExists` instead of repeating the same try/access/catch.
- src/cli/commands/tracker.ts: the `resolveTrackerCliAction` doc comment now
  says why the resolver stays standalone (unit-tested over the (current,
  requested) matrix) rather than describing which branch reaches it.
- tests/tracker-install.test.ts: drop the local `refsRoot` duplicate of the
  file-level `refsTarget`.
- tests/tracker/single-authority.test.ts: three identical inline filters for
  "the tool-call providers" become one `toolCallProviderIds` helper.

Refs #350
…re graph stays linear

`@mdscript/mds@0.2.0`'s resolver deep-copies the captured scope of a SELECTIVE
import and then re-snapshots the whole captured set once per `@define` in the
importing module, so the imported graph is copied once per define. `_jira.mds`
and `_linear.mds` are the two modules that selectively imported 8 names from
`_mcp.mds` plus 7 from `_common.mds`, and they paid for it: ~4.6 s each to
compile, roughly doubling `npm run build:mds`. That is invisible locally and
fatal on CI, where one vitest run spawns ~52 full builds (47 in
tests/build-mds-generator-hosts.test.ts, 3 in tests/build-mds.test.ts, plus the
memoised buildCommittedTree in tests/helpers.ts) and a 2-core runner then pushes
a single build past the 60 s spawnSync timeout. Run 35472010050 failed exactly
there, as three unrelated-looking ETIMEDOUTs in build-mds.test.ts,
build-mds-generator-hosts.test.ts and seams/pr-link-handoff.test.ts.

Alias imports (`@import "./_mcp.mds" as mcp`, call sites `{mcp.name()}`) capture
shallow. They change lookup, not expansion, so the emitted bytes do not move.

Timings (in-process compileFile, same machine):

  module      before     after
  _mcp.mds       1 ms      1 ms   (unchanged — imports nothing)
  _github.mds   13 ms     13 ms   (unchanged — 3 selective names)
  _jira.mds   4634 ms     22 ms
  _linear.mds 4643 ms     21 ms

  npm run build:mds   9967 ms  ->  873 ms

`diff -r` of dist/skills, dist/commands and dist/agents, built from the
pre-change sources versus the post-change sources: empty, all three.

- src/assets/mds/tracker/_jira.mds, _linear.mds: both imports converted to the
  alias form, every call site of an imported rule prefixed, and a header
  paragraph stating why the alias form is load-bearing so the next editor does
  not "tidy" it back.
- tests/build-mds-compile-time.test.ts (new): budgets each reference module at
  1,500 ms — ~60x the measured ~22 ms, ~3x under the 4.6 s cliff — and names the
  cliff in the failure message. Its known-bad probe reconstructs the selective
  spelling from the shipped module, proves the two emit identical bytes, and
  requires the reconstruction to blow the budget, so the guard cannot go vacuous
  (PF-018). Proven RED against the pre-fix sources: _jira 4591 ms, _linear
  4509 ms over budget.
- tests/provider-literals.test.ts: the "imports the authoring module and invokes
  every rule" arm matched one of MDS's two import spellings and the bare call
  site only. Same claim, now held against the `@import` directive and against a
  call site with or without an alias prefix, via two named predicates with their
  own seeded negatives. No assertion relaxed — a module merely NAMED in prose
  still fails the import check, and a sibling rule still fails the invocation
  check.

Refs #350
The dedup-ladder seam requires each tool-call provider module to invoke the
shared `dedup_ladder` define exactly once, and counted that by splitting on the
literal `{dedup_ladder()}`. The provider modules now reach `_mcp.mds` through an
alias import, so the one call site is spelled `{mcp.dedup_ladder()}` and the
count read 0.

The claim is unchanged and unrelaxed: exactly one invocation, bare or
alias-prefixed, counted by a named collector whose seeded negatives show it
still reports two call sites as two and still refuses to count the define's own
declaration or a mention of its name in prose.

Refs #350
…hen there is none

`converged` answers "did this run copy the agent", and both call sites gated the
presence sentinel on it. That is wrong in both directions.

SUPPRESSING A WRITE IS NOT FAIL-CLOSED. On github -> jira the sentinel is absent
anyway, so "skip the write" and "leave nothing advertising a missing agent"
coincide, and that is the only transition either call site tested. On
jira -> linear they come apart: jira's sentinel is already on disk, so skipping
the write leaves it advertising a provider whose agent the run just failed to
install — the exact state the suppression exists to prevent — while the message
told the user "nothing advertises a provider whose agent is missing".

READING `converged` ALSO DISABLES WORKING INSTALLS. A re-copy that fails over an
already-installed agent (transient EACCES, a full disk) reports converged=false
with a perfectly spawnable file still on disk. Suppressing there turns a
transient I/O error into a silently disabled provider.

So the gate is PRESENCE, not success. `ConvergeTrackerArtifactsResult` gains
`agentPresent`, probed at the target on each failure path rather than inferred
(the github arm answers it without a probe — it knows what it removed). Both
call sites write the sentinel when the provider is advertisable and REMOVE it
otherwise, through `applyTrackerSentinel`, which is the one sentinel owner
(D-TRACKER-OWNER) — never an inline fs.rm at a call site. The warning now says
what happened.

RED proved first: the two new runTrackerSet arms and the two new
persistManifestThenConvergeTracker arms fail against the parent, the first pair
on `sentinel:github` never being called and the second on `sentinel:linear`
never being called.

Pins classified (applies ADR-025): RE-POINTED 2 — the two "unconverged agent
suppresses the WRITE" arms keep their subject (the write does not happen) and
gain the removal that makes the claim true; the message pin moves from
`sentinel not written` to `sentinel removed`. STRENGTHEN 5 — four new arms
covering the transition both existing arms could not see, plus
convergeTrackerArtifacts' own agentPresent arms including the
failed-re-copy-over-a-present-agent case. SOFTEN 0, DELETED 0.

Refs #350
The positive shape gate was applied to the CONCATENATION
"$PROJECT_ROOT$TRACKER_DEVFLOW_DIR", and only Section 3 interpolates both.
Section 2 embeds $PROJECT_ROOT alone (the Learning spawn directive's
`Project root: $PROJECT_ROOT`), so a ~/.devflow path outside the allowlist
suppressed the Learning directive over a value that directive never carries.

That is a regression against the denylist it replaced, and it is silent and
permanent: the old list was `"`, `\`, CR, LF, none of which a space matches.
A home directory containing a space — or `+`, `~`, `@`, `,`, `:` — now disabled
the entire learning pipeline for that machine, every session, with `dbg` the
only trace. Reproduced with a clean project root and a DEVFLOW_DIR carrying a
space: the directive is absent; with a clean DEVFLOW_DIR it is emitted.

Fail-closed is only the safe direction when the gate refuses a sink its value
actually reaches. So it is one gate per VALUE:

  DIRECTIVE_ROOT_SAFE   $PROJECT_ROOT          consulted by Section 2
  DIRECTIVE_PATHS_SAFE  + $TRACKER_DEVFLOW_DIR consulted by Section 3

seeded from the narrower flag, so the wide one can only ever be narrower and
the two cannot drift apart. No character is newly admitted at either sink: a
hostile project root still refuses both directives, which the four
OUTSIDE_ALLOWLIST arms continue to prove. The `''` arm also stops being dead
code — under the concatenation an empty $PROJECT_ROOT could only reach it if
$TRACKER_DEVFLOW_DIR were empty too, which lines 45-57 make unreachable, so a
test asserting the arm's presence was pinning a control that controlled nothing.

Also corrected in the same block: the comment claimed the range rejects "any
non-ASCII character". A `case` bracket range collates under LC_COLLATE, and
`A-Za-z0-9` admits an accented letter under a UTF-8 locale (verified:
/Users/cafe-with-an-accent/p is admitted under en_US.UTF-8, refused under C).
Every byte the gate exists to refuse is outside the range in both locales, so
the security property is unaffected — but a comment stating a guarantee the
code does not provide is the comment a future reader relies on.

Pins classified (applies ADR-025): STRENGTHEN 3 — collectGuardedSections now
names a flag PER SECTION, so a section reading the wider flag is reported
rather than passing; its known-bad probe drives that exact coupling (and keeps
the original no-flag-at-all defect, plus an underived-preamble case); and the
allowlist arm requires two matchers and both `case` heads, so one gate over a
concatenation fails by shape. RE-POINTED 1 — the gate-block slice anchor moves
to the first flag. New behavioural arm: a hostile ~/.devflow suppresses the
tracker directive and spares Learning, proved RED against the coupled gate.
SOFTEN 0, DELETED 0.

Refs #350
Each arm below produced its expected result whether or not the thing it names
was true, which is the PF-018 shape the files they live in claim to avoid.

REQUIRES CLOSURE, REVERSE ARM. The known-bad probe built a synthetic plugin with
no commands, no agents and no skills, then asserted that its corpus does not
reference 'nonexistent-skill'. An empty corpus references nothing, so the
assertion was a tautology: it passed for a gutted collectPluginRefs, never
constructed a violation string, and never ran the reverse loop or its
`requires.length === 0` skip. The loop is now a named collector the live arm
calls, and three probes drive it — a REAL plugin's corpus with its requires list
kept whole plus one entry nothing can reference (so the collector must
discriminate one dead entry from several live ones, not just report everything),
a plugin whose requires entry has no corpus that could reach it, and the empty
list. Keeping the base plugin's requires whole is load-bearing: the scan's scope
is `skills ∪ requires`, so dropping entries shrinks the corpus and reports live
entries dead for the wrong reason.

THE N=8 CLAIM RACE. `mv src dst` wins unconditionally, so the rename-to-claim
control reports N winners whether the claimants raced or ran one after another;
`set -o noclobber` reports exactly one winner under both too. The two arms were
therefore indistinguishable under a serialised harness, while the comment on the
probe said a single winner there would mean the harness was not concurrent — it
would not, and the harness comment's "`mv` would also lose the second time" is
the same error (it wins every time, which the probe's own result shows). Nothing
observed the race. `runShellAsync` now reports its wall-clock window and a named
`overlapWindow` collector returns the interval during which every run was
simultaneously alive; both arms assert it is positive before reading their own
result, and the collector has its own probe over seeded overlapping and
back-to-back windows. No claimant assertion is relaxed.

THE DEGRADED-REASON PARSER'S BLIND SPOT. `collectDegradedReasons` bounds nesting
at one level and requires balance, and both limits fail by matching NOTHING — so
a reason with unbalanced or doubly-nested parentheses is invisible to the
registry arm rather than reported by it, and the arm certifies a corpus it did
not fully read (avoids PF-064). `collectUnregisteredReasons` now counts
`DEGRADED (` sites against parses and reports the shortfall as its own
violation. Verified over the shipped corpus first: 0 dropped today, so this adds
a guard rather than a green-to-red change. Its probe drives both unreadable
shapes through the collector and confirms a readable nested reason is not
reported by the same check.

Refs #350
…that outlived their subject

`resolveTrackerCliAction` was live on main and is not live here: `runTrackerSet`
replaced it and builds its own messages, so nothing but its own unit tests
imports it. Its JSDoc argued for keeping it as a testable resolver, and that is
the trap — the two strings it produces (`Tracker provider set to jira`,
`Tracker provider already jira`) reach no user, so four tests were pinning a
message contract with no consumer, and a correction made to either string would
have had no observable effect. Deleted with `TrackerCliActionResult` and its
tests; `TrackerCliActionMessage` stays, because `runTrackerSet` uses it. The
(current, requested) matrix it covered is covered by runTrackerSet's own arms,
against the messages users actually see (ADR-003).

Four references whose subject this branch moved or deleted:

  installer.ts             OverlayFailureState pointed at `formatOverlaySummary`
                           in init.ts; the renderers moved to install-report.ts,
                           and the sentence-per-arm rule it means is
                           describeOverlayFailureState's.
  byte-budget.test.ts      the jira ceiling's JSDoc named `BUDGET_LOADED_SET_P3`,
                           deleted when the GitHub row collapsed onto one number,
                           and restated that row's headroom as 107 ch — the
                           collapse re-measured it at 45, which the same file's
                           own JSDoc and numeric-floors.json both say. Two
                           documents in one file disagreeing about one number is
                           worse than neither stating it, so it now points at the
                           printed table instead of carrying a copy.
  build-mds-compile-time   the warm-up comment said it used "a module this file
                           does not time"; `_mcp.mds` is on the timed roster.
                           Warming on the cheapest roster module is correct — the
                           comment's reasoning was not.
  core/tracker.ts          a `/* */` block inserted between the JSDoc and
                           `trackerConventionsBackupName` detached that JSDoc
                           from the function, and spoke of "the two helpers
                           below" from above only one. Merged into the JSDoc,
                           naming the sibling it also covers.

No value, assertion, ceiling or floor moves.

Refs #350
…viders ship

The Code agent's per-provider paste arms are a FOURTH reader of each provider's
reference grammar, and the only one no guard covered. The project-key alphabet
already has an explicit one-authority claim over three readers — the Git
preamble, the Tracker agent's schema table and every `KEY-N` grammar in the
provider mechanics (tests/tracker/single-authority.test.ts) — and this gate was
outside its corpus, because it spells the whole ref grammar rather than the bare
key alphabet the collector there matches.

Being a fourth READER is correct and stays: the Code agent is outside the Git
spawn surface and loads no mechanics file it could defer to, so a sink check has
to enumerate the closed set once inside the gate (PF-023). Being a fourth
AUTHORITY is what nothing was stopping. The arms agree with the mechanics today,
byte for byte; what was missing is anything that would notice if a provider's
grammar were edited and this table were not, and the dangerous direction is
silent — a widened arm admits a link line the provider's own mechanics refuse,
at the one gate between an attacker-influenceable value and a GitHub-visible
sink.

`collectProviderRefGrammars` reads each provider's grammar out of the shipped
generated tree and requires it to be the single distinct `KEY-N` shape that
provider states; the arm is then asserted to be exactly that grammar with the
rendered verb spliced in after `^`. Nothing is re-typed here: a literal in this
file would be the fifth authority and the only one nobody ships (PF-018).
Linear's internal-id form is excluded by the collector's shape, which matches
the provider's own rule — an internal id carries no team, so the history grammar
admits the TEAM-KEY form only and a rendered PR link is always that form.

Two non-vacuity arms: the collector must find exactly one grammar per provider
(or the comparison is between two absences), and the two providers' grammars
must differ (or the mix-up the jira/linear overlap makes plausible could not be
reported). RED proved by widening the jira arm's key alphabet from `{1,9}` to
`{0,9}` in a copy of the prompt — reported with both strings named, then
reverted.

No prompt asset, golden or ceiling is touched.

Refs #350
Removes remaining references to tests/tracker/containment.test.ts,
CONTAINMENT_EXEMPTIONS, tests/fixtures/tracker/baseline/,
tests/guards/guard-census.test.ts, git-agent-guard-count,
PRE_PHASE3_REASONS, BUDGET_LOADED_SET_P3, and resolveTrackerCliAction
across the six affected KNOWLEDGE.md files and index.md, rewritten as
end-state prose pointing at their live successors
(single-authority.test.ts / reference-reachability.test.ts,
reference-floor.ts, GITHUB_ONLY_REASONS, BUDGET_LOADED_SET,
runTrackerSet). Completes commit ab612e0's claim, which missed these
sites.
…nged (F-1, AC-23)

overlayGeneratedReferences pushed every promoted unit's files into
overlaidRefs on every call, so `moved` in `tracker --set` was true on
every run and the `(unchanged)` branch was unreachable — the only test
of it stubbed `overlaid: []`, a state the real overlay never produced.

Compare each staged unit against the installed one between the build and
the promotion, skip the promotion when they match, and report those
files in a new `unchangedRefs`. `overlaidRefs` now names what a run
WROTE, which is what `tracker --set <same provider>`'s `(unchanged)`
line, formatOverlaySummary and init's `Tracker assets: +N` all read.

The comparison is against what the promotion would DO, not the manifest
alone: a provider unit is swapped whole, so an installed file the
manifest no longer names makes the unit changed — converge-not-merge is
not downgraded to a merge. File modes are deliberately out of scope;
D-OVERLAY-MODE-SCOPE normalises the whole directory every run regardless.

RED: `runTrackerSet` through the real `buildTrackerSetIO` printed
`Tracker: github - 13 installed, 0 removed` on a repeat set; the overlay
run twice into a mkdtemp target re-promoted every unit (new inodes).

Refs #350
…d (F-2, M-1)

The setup-task spawn forwarded the first $ARGUMENTS token as ISSUE_INPUT
unless it ended in .md, so `/implement fix the login bug` reached the Git
agent as `ISSUE_INPUT: fix` with TASK_DESCRIPTION omitted — and
setup-task fetches whatever it is handed, with no shape gate of its own.
The request was dropped and a branch derived from a failed lookup.

Gate on shape instead: a single whitespace-delimited token that does not
end in .md is ISSUE_INPUT, two or more tokens are TASK_DESCRIPTION, a
.md path is PLAN_ARTIFACT_PATH. Token count keeps the provider
neutrality the `#`-only gate was removed for — `#42`, `42`, `PROJ-12`
and `ENG-7` are each one token, and no prose description is.

The tests stop pinning the sentence and assert the routing each SHAPE
produces. The line this command shipped becomes the known-bad probe for
a new labelled collector rule: an unconditional first-token forward is
perfectly provider-neutral and still loses the request.

RED: all three new arms failed against the pre-change compiled command,
the collector reporting
`ISSUE_INPUT: {the first $ARGUMENTS token verbatim, ...} - an
unconditional first-token forward`.

Refs #350
…t (F-3, M-2, AC-3)

gather-release-evidence stated GitHub's stop rung inline — "Secondary
rate limit (403/429 or `X-RateLimit-Remaining` < 10)" — while jira and
linear both point at `### Provider signals ({Provider})` in their
backlink-shipped-issues reference. Two statements of one threshold, free
to drift; AC-3 asks for one.

Replace the restatement with the same pointer shape the other two
providers use. The outcome the operation owns (stop, report the
remainder as THROTTLED) stays where it is; only the threshold moves to
its authority.

The exact-string guard could not see this: it pinned the two spellings
the authority file uses, and the restatement used neither. Widen it to
the bare `X-RateLimit-Remaining` token and assert backlink-shipped-
issues.md is its only home, with a non-vacuity arm so a deleted rung
cannot read as a clean tree.

RED: the widened arm reported `github/gather-release-evidence.md`
alongside the authority.

Refs #350
…ture count (F-4, M-3)

Three [Unreleased] statements this branch falsified:

- the Git-agent bullet described a 4-rung first-hit-wins resolution with
  a 60% ref-grammar corroboration scan. Shipped: three rungs, the
  per-repo `tracker` key NARROWS the manifest rather than beating it,
  rungs 1 and 2 are read before anything is decided, and the issue
  grammar is not a rung at all. No corroboration scan exists.
- the byte-budget bullet called the pre-split 77,824 a live ceiling. The
  loaded set is priced per provider now: 80,200 GitHub, 89,500 jira,
  91,700 linear.
- the fixture bullet said the golden "was re-captured twice". This
  branch spent the third authorisation.

Also drops the same bullet's claim that github-status-lines.txt is
byte-identical to the pre-change capture, which the fixture bullet three
entries below already contradicted.

Guard, so the next drift is caught rather than reviewed: CHANGELOG.md
joins the retired-wording corpus, reduced to the [Unreleased] section's
After-halves — a released section is a record, and a before/after
bullet's title and before-half exist to NAME what was removed. The four
phrases are registered as scoped entries. `close milestone` gets an
explicit scope for the same reason: the changelog's end-state sentence
for that fix has to name it.

The scope known-bad probe now runs over EVERY scoped entry against a
synthetic outside path, rather than the first entry found against
`docs/` — a later entry whose scope named docs/ broke it.

RED: the four literals reported against the pre-change CHANGELOG.

Refs #350
…itle to its constant (F-7, F-5)

F-7 (M-6): CLAUDE.md stated one default, `Refs {KEY}-{n}`. That is Jira's
token; Linear's documented default is `Refs {REF}-{n}`. The shape is
stated once, provider-neutrally — the per-provider token is a provider
fact and lives in that provider's own mechanics, which is where both
`create-release` and `ensure-pr-ready` state it. Naming both here would
have cost 61 of the block's 42 remaining characters; the cap is not
raised to fit the sentence. Block measures 3,477 of 3,500.

F-5 (M-4): the preamble test's title said `<= 40 lines` while
PREAMBLE_MAX_LINES is 36. Interpolated, so the two cannot disagree again.

Refs #350
(a) AC-17 — backlink-shipped-issues had no executed gate evidence. New
tests/tracker/compliance-gate.test.ts asserts the matrix for github,
jira and linear: /release step 4b spawns the operation exactly once and
carries the compliance condition, step 2b gates the SHIPPED_ISSUES it
consumes on the same one, and no provider's mechanics file states a
compliance condition of its own. The ungated half is not a bare absence
— each provider file must still carry the entry gate and the
never-COMPLETE-over-zero rule it actually ships, so an empty file cannot
pass. RED shown by mutating dist: a stripped gate and a seeded
per-provider condition each failed their own arm.

(b) AC-12 — two payloads added to the EXECUTED paste matrix:
`pr-link: $(whoami)` (the value is pasted into a PR body a shell
composes) and a 74-character line whose only defect is length. RED
shown by claiming github accepts the substitution: the extracted arm
`^Closes #[1-9][0-9]{0,8}$` rejected it.

(c) AC-26 — the dry run's plan and the real removal each resolve
installedPlugins from the manifest independently, and nothing compared
them. One seeded manifest, one seeded skill tree, both paths run: what
the removal took must equal what the plan named, and what remains must
be the complement. RED shown by handing the real path DEVFLOW_PLUGINS:
it removed nothing while the plan named three skills.

(d) L2 — the init.ts predicate behind pluginListUnchanged was inline and
unexecuted while its renderer was covered exhaustively. Extracted as
isPluginListUnchanged in install-report.ts, beside the renderer that
consumes it, and unit-tested in both directions: a null prior manifest
is never unchanged (the empty-vs-empty case a set comparison alone gets
wrong), set equality ignores order and duplicates, and a fourth arm
feeds the predicate into the renderer so the two halves are joined.

Refs #350
QA S2: `init --tracker jira --recommended` run twice reported
`Installed 24 generated skill reference(s)` and
`Tracker assets: +24 reference(s), −0 reference(s), tracker agent installed`
on EVERY run — reference mtimes advanced while sha256 stayed identical.

Root cause: the full-install pre-clean `fs.rm`'d every directory in
skillsMap before overlayGeneratedReferences ran, so
stagedUnitIsAlreadyInstalled had no installed copy to compare against and
every unit was force-promoted. `devflow tracker --set` never passes through
the pre-clean, which is why its `(unchanged)` line was already correct.

The pre-clean now empties the references-hosting skill directory around the
paths the overlay owns — derived from installedReferenceManifest, never a
hand-typed list — and empties everything else exactly as before, so a stale
hand-authored skill file or a reference at the references ROOT (which the
overlay may replace but never delete, D-OVERLAY-FLAT-UNIT) still does not
survive. Converge-not-merge is unaffected: the overlay's own prune removes
whatever under references/tracker/** the manifest no longer names. The
sweep/pre-clean "deliberately opposite scoping" comment stays true; the new
split is between the pre-clean and the overlay, stated at the pre-clean and
in D-OVERLAY-OWNERSHIP.

convergeTrackerArtifacts gets the same treatment: it byte-compares the
source against the installed agent and reports `unchanged` when the copy
would change nothing. Drift is still converged and still reported as
written — a hand-edited agent is restored and reported `installed`.

InstallReport carries `unchangedRefs` so "this run wrote nothing" is an
assertable outcome rather than an absence indistinguishable from an install
that never reached the overlay.

Refs #350
…s it

Plan A3 and the Gate-1 decision: the Tracker agent stays declared in
`devflow-core-skills.agents` and is FILTERED AT INSTALL TIME, with
`convergeTrackerArtifacts` the one owner of whether
`{claudeDir}/agents/devflow/tracker.md` exists. The generic agent copy
loop in installer.ts wrote it too, so both owners acted on every install
and contradicted each other in both directions:

  init --tracker github (fresh)
    Tracker assets: +13 reference(s), −0 reference(s), tracker agent removed
  init --tracker github (re-run)
    Tracker assets: +0 reference(s), −0 reference(s), tracker agent removed

— a removal reported for a file only that same run had put there — while a
fresh `init --tracker jira` never said `tracker agent installed`, because
converge found the loop's byte-identical copy already in place.

The loop now skips the one agent it does not own, and the full-install
pre-clean empties `agents/devflow/` AROUND it instead of wiping the
directory whole. Both halves are needed and both read ONE authority, the
newly exported `TRACKER_AGENT_NAME` (D-TRACKER-AGENT-OWNER): the pre-clean
is the same split the reference tree needed in 772f052
(D-OVERLAY-OWNERSHIP), for the same reason — taking the file leaves
converge with nothing to byte-compare against, so a steady-state jira
re-init re-copies the agent and announces it every run.

Nothing about the registry changes: `tracker` stays declared, the roster
count stays 17, and the agent sweep still keys on getAllAgentNames(), so a
converged tracker.md is never an orphan. Uninstall is unaffected — it
removes `agents/devflow/` wholesale.

After the fix, live against a temp HOME:
  github fresh   Tracker assets: +13 reference(s), −0 reference(s)
  github re-run  (no Tracker assets line)
  jira fresh     Tracker assets: +24 reference(s), −0 reference(s), tracker agent installed
  jira re-run    (no Tracker assets line)
  tracker --set github from jira   ... tracker agent removed

The precedence guard now completes its tree through the agent's real
owner rather than dropping it from the population, so the one-authority
claim about `agentSourceDirs()` still covers all 17.

Refs #350
…55,750 base)

Reviewer decision on PR #351: the phase-named ceiling pair is kept, so the
two [Unreleased] lines that still cited the file measuring under 55,750 now
state the gate as shipped.
@dean0x
dean0x merged commit af38234 into main Sep 22, 2026
2 checks passed
@dean0x
dean0x deleted the feat/350-selection-scoped-install-bundle branch September 22, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Selection-scoped install bundle + tracker gap closure (post-#344)

1 participant