feat(tracker): pluggable issue tracker with Jira and Linear providers - #344
Merged
Merged
Conversation
Tracker Phase 3, subtask 3a-1 (part 1 of 2) — the selection substrate.
Adds `manifest.features.tracker = { provider }`, an enum over
github|jira|linear defaulting to github, chosen at `devflow init` on both
wizard paths, settable non-interactively with `--tracker <id>` and
afterwards with `devflow tracker --set <id>`.
GitHub users — the default, and every existing install — see no prompt,
no new file and no changed behaviour.
- `src/core/tracker.ts` (new): the provider registry, the strict boundary
parser `parseTrackerId`, the tolerant sink normaliser
`normalizeTrackerFeature`, the shared `features.tracker.provider` key
path, and the three ~/.devflow file-lifecycle owners —
`rearmTrackerInference` [DR-22], `applyTrackerSentinel` [DR-10] and
`renameStaleTrackerConventions` (P3a-S15). Parsing REJECTS, never
repairs: `jira-cloud` errors rather than becoming `jira`, so a typo can
never select mechanics the user did not name.
- `src/cli/commands/tracker-prompts.ts` (new): the four-part wizard-step
contract, mirroring compliance-prompts.ts. `shouldRunTrackerStep` gates
BOTH wizard paths on `modePromptShown`, never on the mode name, so
`--recommended` and the non-TTY fallback keep their promptless
contracts (PF-029). `runTrackerStep` never exits and never throws
(PF-014) — the caller owns cancellation.
- `src/cli/commands/tracker.ts` (new): `devflow tracker --status/--set`.
`--status` reports the selection and tracker.md's provenance; `--set`
moves a stale conventions file aside, persists through the already
generic `syncManifestFeature`, re-arms inference, and converges the
presence sentinel.
- `init.ts`: the `--tracker <id>` option, a boundary parse before any
prompt, the wizard step on both paths behind the one shared predicate,
the Recommended summary row and the mandatory Advanced outcome line,
and the single post-resolution lifecycle block.
- `manifest.ts`: `features.tracker` is absent-tolerant and is NEVER in
the hard-null validation set — a pre-tracker manifest, i.e. every
existing install, must keep parsing (AC-3.21).
- `init-seed.ts`: seed, registry default, and the mandatory defensive
spread so the module-level default is never handed out by reference.
Refs #325
Tracker Phase 3, subtask 3a-1 (part 2 of 2) — the uninstall classification and the @d8 disjointness boundary. `~/.devflow/tracker.md` is USER CONTENT: it is inferred once per machine and then hand-editable, and its ABSENCE is the trigger that re-runs inference, so removing it on the decline/cancel/non-interactive/ --keep-docs paths would silently discard work the user may have corrected by hand. It is enumerated between preference-profile.md and learning.json; `resolveDevflowDirCleanup` needed no change — a `userContent` entry automatically flips a user-scope interactive uninstall from 'artifacts-only' to 'prompt'. That flip is the one accepted UX regression of Phase 3: a user with no other user content previously got a silent artifacts-only sweep and now sees a confirm prompt. The three runtime files beside it — `.tracker.processing` (the agent's atomic claim), `.tracker.attempts` (the inference attempt counter) and `.tracker.enabled` (the presence sentinel) — are INSTALL ARTIFACTS, so an artifacts-only sweep takes all three and keeps tracker.md. The two lists stay disjoint (@d8); test 9f's non-vacuity floor rises 5 → 6 and 9c's residue equality now pins tracker.md as surviving while the three artifacts are asserted gone. REVERSAL CONDITION, recorded at the code site: this classification is conditional on the provider-mismatch guard shipping in the same 3a group. agent-models.json was reclassified to an artifact precisely because stale overrides re-apply *silently*; a stale tracker.md is only safe to preserve because a frontmatter provider that disagrees with the resolved provider produces a named DEGRADED and no tracker call. If that guard is ever dropped, reclassify tracker.md to an install artifact in the same change. Refs #325
The 17th agent. Spawned only by the session-start setup directive, it probes
what the configured tracker can do, infers repository conventions from a bounded
history scan, and writes ~/.devflow/tracker.md exactly once — or writes nothing.
A defaults-only file would satisfy the existence gate forever and destroy the
retry trigger, so a session that cannot reach any tracker capability writes
nothing, records the attempt, and leaves the next session to try.
It carries the ~/.devflow/tracker.md schema template (the writer half of the
schema; the reader half follows), a scrub-gated create-exclusive write, and the
attempt-counter final-act rule the session-start cap depends on: without an
incrementer in the one place that knows a run produced nothing, that cap never
engages.
Registered in the commands-less devflow-core-skills plugin beside `learning`,
which is what makes the Guard-5 reverse check pass structurally rather than by
exemption. No _roster.mds row: that file is set-equal to the agentType values in
dist/commands/, and a hook-spawned agent appears in none of them.
Declares no `tools:` key deliberately — tracker server names are user-configured
and cannot be enumerated at authoring time, so an allowlist would fail at
runtime in an unwatched background run rather than at build time. A read-only
boundary section compensates, and is mechanically pinned.
The agent is provider-agnostic: the validated token arrives in the spawn
directive, so naming a provider here would be a second resolution site as well
as provider-module vocabulary landing before its module.
Guards, in the same commit because the prompt's rules are otherwise unobservable
(the agent runs unattended and its summary is never read):
tests/tracker-agent.test.ts identity, model tier matched against
loadShippedDefaults(), the absent tools: key,
the read-only boundary, the claim/heartbeat/
final-act lifecycle including the
counter-before-claim ordering, the write
chain, the name-not-copy rule for
references/learn-conventions.md, and the
template's ordered heading list
tests/tracker/hostile-values the field x payload matrix, with shapes read
out of the agent's own schema table rather
than restated, so a validator that drifts
laxer fails here instead of being proven by a
private copy
tests/helpers.ts the shared heading list and table parsers —
the writer/reader equality test that follows
cannot catch drift in its own oracle
numeric-floors.json agent-roster-count 16 -> 17 with its resolver assertion, in
this same commit: a floor may only rise, and the registration is what moves it.
Refs #325
describeTrackerValue's sanitiser class was written with RAW control bytes (a literal NUL, US and DEL inside /[...]/). The NUL made grep classify src/core/tracker.ts as binary — it printed "Binary file matches" and skipped the lines — so every grep-based guard over src/core/ silently stopped covering this file while still exiting 0, and `git diff` rendered any change to the class as "Binary files differ". The escaped spelling is behaviour-identical and greppable (grep -c "^export" now reports 23 instead of nothing). Adds tests/guards/no-control-bytes.test.ts: a named collector over every shipped source file under src/ (.ts/.md/.mds/.cjs/.js/.json plus the extension-less shell hooks), forbidding 0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f and 0x7f while allowing tab/LF/CR, with an inline known-bad probe per forbidden range asserted inside the same it (R1(b)) and scope sentinels resolved through agentsDir()/scriptsDir(). RED against the pre-fix tree: src/core/tracker.ts:160: raw 0x00: expected [ '...' ] to deeply equal [] Refs #325
… start
Section 3 of session-start-context: when this machine's manifest names a
non-GitHub issue tracker and no ~/.devflow/tracker.md has been inferred
for it yet, inject a silent "--- TRACKER SETUP ---" directive instructing
the main model to spawn the background Tracker agent.
[DR-10] The gate is two shell builtins and nothing else, so a GitHub user
pays one stat and ZERO forks per session. The .tracker.enabled sentinel is
what makes that possible: tracker.md is written only for jira/linear, so a
bare "does tracker.md exist" early exit would never fire on the default
provider and every SessionStart would fall through to the manifest read.
Proved differentially at runtime with an additive PATH shim that records
every exec of jq/node/date/stat (PF-045 — nothing is subtracted), with a
positive control on the jira path so the zero is not a counter that never
moves.
Five gates, cheapest first: the sentinel + tracker.md existence tests
(builtins), the OD-14 attempt cap (read builtin), source in
{startup, clear}, claim-file freshness, then the provider allowlist.
The provider case is a POSITIVE allowlist (jira|linear) that runs before
any interpolation — never != github — so a hand-edited manifest value
carrying quotes or newlines cannot reach additionalContext; a 21-row
hostile table asserts the payload appears nowhere in stdout or stderr.
[DR-02] The hook increments .tracker.attempts when it EMITS, so a crashed
agent that never reaches its own increment still burns an attempt. The
counter's shape is one decimal-integer line, parsed defensively: read's
exit status is not consulted (a missing trailing newline is not a read
failure), a non-digit value self-heals to 0 and is rewritten well-formed,
and a 7+ digit value is treated as AT the cap because [ "$N" -ge 5 ] past
intmax_t fails OPEN.
TRACKER_PROCESSING_STALE_SECS=600 is its own named literal, never shared
with Learning's 900: one constant would make a change to either feature
silently reclassify the other's live runs as crashed.
The resolved-root idiom ${DEVFLOW_DIR:-$HOME/.devflow} is captured ABOVE
the project-scoped DEVFLOW_DIR assignment that shadows the inherited env
value, with a comment at both sites.
Also:
- tests/seams/tracker-key-path.test.ts — the TS-to-shell seam for
TRACKER_PROVIDER_KEY_PATH: exactly one live reader in the hook, the
allowlist read out of the hook rather than restated, and 14 manifest
shapes x 2 json-parse backends asserted against a hand-pinned oracle
(the two readers legitimately return different tokens for a malformed
shape and must still reach the same verdict).
- AC-3.22: every session-start-context invocation in
config-disable-guards.test.ts now runs under a seeded temp $HOME with
DEVFLOW_DIR='' — two of its emptiness assertions previously inherited
the developer's real HOME and went red locally for any maintainer who
had run `devflow init --tracker jira`, while CI stayed green.
- numeric-floors.json: new ceiling tracker-section-max-chars (800,
measured 722) over the directive template.
Refs #325
The first step of the Git agent's provider resolution order (OD-9): the `tracker` key in a project's `.devflow/config.json`, which is the only signal that can say "this repo, specifically". Three states, none collapsible. Absent means NO override and requests ref-grammar corroboration -- deliberately not the same as a chosen `github`, which short-circuits it. Valid is a byte-exact registry id. Invalid carries the raw value so `DEGRADED (unknown tracker provider)` can name it; that reason exists only because "invalid" is distinct from "absent" (DR-26), and the manifest value's malformed case self-heals silently instead. parseTrackerOverride delegates membership to parseTrackerId rather than copying reviewPublication's closed-domain ternary: that ternary heals any invalid value to 'auto', which is right for a publication mode and wrong for a provider -- a repaired provider is GAP-10's laundering path, and repair is forbidden here (14.9-6, reject-never-repair). The field is carried through coerceConfig VERBATIM because updateFeature is a read-modify-write over the whole config: a key it dropped would be a key `devflow knowledge --disable` DELETES, silently reverting a Jira repo to the manifest provider through an unrelated toggle. That round-trip is the reachable consumer the key has at this boundary (ADR-003), and it is pinned as its own test. BooleanFeature gains `-?`: a mapped type over an optional property yields `K | undefined`, so a bare mapping stopped compiling the moment the optional field joined the interface. Refs #325
GAP-04. The file-sink gate is a shell `&&` chain; a tracker reached through a tool call has no `--body-file` and no shell operator between the scrub and the post, so the `&&` cannot exist and the gate silently degrades to an instruction. `--emit` restores a mechanical one: the scrubbed bytes are obtainable only from behind a framing line this script alone can produce. D11-OK <nonce> <sha256> <bytes> <n> [type:count,...] <the scrubbed body> Each field answers a specific channel failure. The nonce is per-invocation and required (14.9-3): composed bodies carry untrusted issue text, so a fixed literal would be forgeable by anyone who can write a comment. <bytes> is the UTF-8 length (DR-06) so a consumer can detect a harness-TRUNCATED result -- truncation leaves line 1 intact, so a bare "no framing line means do not post" gate passes while the body is partial. <n> [type:count,...] is the FIRST pass's count (DR-01); the second pass is always zero by construction, so without it the only signal that a real credential was present is computed and discarded and the user is never told to rotate it. The second scrub pass returning `SCRUB: 0 []` IS the gate. New exit code 5 covers a non-zero second pass and an unavailable nonce, distinct from 4 so a caller can tell "the scrub did not hold" from "the script broke". The normative single-stdout-boundary block is AMENDED, not bypassed: main's return widened to carry an emit triple and the write became two-branch. Still exactly two stdout write sites, asserted with a seeded third. No body accompanies any non-zero exit as a property of the RESULT type rather than a rule each arm must remember -- a failing arm carries `body: ''`, so the boundary writes unconditionally and cannot leak one by forgetting to suppress it. All six failure paths assert it anyway. Decomposed per DR-14 into parseArgs / scrubTwice / frameEmit with main as a dispatcher, and the helpers exported behind `require.main === module`: parseArgs is otherwise observable only end-to-end, and the nonce-failure arm is not observable at all -- no argv and no fixture can make randomBytes fail, so injection is what makes it assertable. The flag is parsed BEFORE the positionals are bound. Previously argv[2] and argv[3] bound positionally with no flag handling, so `--emit` became a FILENAME and died at statSync reporting a missing input. GAP-54: the four placeholder skips are anchored. They exist to keep author fixtures readable, but a value that merely CONTAINED a placeholder disarmed rule 8 -- the only generic `key = value` rule -- and provider-rendered bodies are exactly what newly flows into a comment sink. The `[REDACTED:` guard stays a contains-check, or the second pass could never return zero and the gate would refuse every body. The `<in> <out>` mode is byte-for-byte unchanged: it still scrubs once, writes the same file and prints only the SCRUB line, asserted as its own regression scope. Refs #325
GAP-02 + hazard H7. The contract that governs every tracker sink reached through a tool call has to be AUTHORED now -- its first consumer is a per-operation mechanics file landing later in this same phase, and a contract written after its callers is a contract the callers were written without (clause (iii) is read per phase, decision D-D). It must NOT be GENERATED now: Phase 2's own AC-2.7 guard asserts its absence after a GitHub-only build, and every GitHub user would otherwise be billed for a reference nothing they can reach ever loads. So the gate is DERIVED, never declared. mcpContractIsGenerated asks whether any registered module lands in a provider directory whose mechanics need the contract; resolveVariantModules appends the contract row only then. A boolean on the module would have been a flag someone has to remember to flip -- the next subtask opens the gate by registering its provider and by nothing else. Both arms are proven now, against an injected registry, rather than discovered when the gate opens. The emitted basename keeps its leading underscore, and that needed a NARROW allowance rather than a relaxed charset: validateContractOutputName demands the prefix where validateOutputName forbids it, because `tracker/_mcp.md` sits beside the provider DIRECTORIES and `tracker/mcp.md` would read as a fourth provider. Relaxing the shared rule would have admitted `_anything.md` as a command or agent basename too -- a widening across three destinations to buy a property one needs (ADR-025). A test proves the name expands TODAY: the row alone would have expanded fine while absent and refused with `invalid-op-name` the moment 3b opened the gate, a build break planted one subtask ahead. The section-marker regex takes the mirrored optional underscore; it is not a containment gate -- the name it captures is checked against the caller's registry, and what may become a PATH is gated by the two name validators. The build gains a THIRD discovery bucket. A gated module is `deferred`, not refused (refusal is right for an UNREGISTERED module and wrong here) and not silent (silence makes authored-but-gated indistinguishable from lost). Counting it as a partial would have been worst: a partial declares no output-dir:, and this declares one -- so the partial count is `total - hosts - deferred` and the build names each deferred module with its reason. The contract itself states its rules in terms of CAPABILITIES, so it needs no provider literal and no transport acronym -- and therefore no provider-scope allowlist entry at all. An exemption was deliberately not taken; the guard instead proves the module is IN scope, since an unscanned file is an exemption nobody wrote down. AC-2.7 is re-scoped, not deleted: the absence is still asserted, but it now means "the gate is shut" rather than "the contract is unwritten", and those are different claims a bare not.exists cannot tell apart. Three arms pin all three facts -- the source is authored, the gate is shut, and the gate opens for the right registry (PF-064). mcp-sink-bypass.test.ts is the compiler for prose that has none: the four clauses asserted against the SOURCE .mds per [E2], each driven by dropping it in turn; the bypass regex red on seven real bypass shapes including `create_comment(body: $DEVFLOW_BODY_RAW)`; and a forward arm whose live corpus is EMPTY at this boundary and ASSERTED empty, so a green run is never read as evidence about provider files that do not exist. Its [DR-01] and [DR-06] known-bads are seeded mechanics omitting exactly one clause each. Two predicates were inert on first write and are fixed with the reason recorded: `\bRAW\b` cannot match `$DEVFLOW_BODY_RAW` (the underscore is a word character) and `\badd[_-]?comment\b` cannot match `addCommentToJiraIssue`. Both boundaries that made them inert were against exactly the spellings they exist to catch. Refs #325
P3a-S13 + P3a-S14. Phase 2's preamble resolved the provider MANIFEST-ONLY
and read no configuration file; this makes the slot real.
Resolution order, first hit wins: the per-repo `tracker` key -> repo
ref-grammar corroboration -> the manifest -> `github`. The corroboration
rule is stated with its prohibition attached, because the obvious rule is
the wrong one: "GitHub remote plus an authenticated CLI implies github"
holds for essentially every non-github user, since devflow deliberately
keeps PR hosting on GitHub whatever the tracker is -- it would disable
the feature for exactly the users it serves (OD-9). The only signal is
whose issue grammar the repo's history speaks, at >=3 occurrences AND
>=60% share.
The mismatch guard is the reader-side invariant: frontmatter `provider:`
!= the resolved provider emits `DEGRADED (tracker configuration
mismatch)` and makes NO tracker call. It covers every path init cannot
see -- uninstall then reinstall, a hand edit, a dotfile-repo sync -- and
it is the precondition for 3a-1 preserving the file as user content on
uninstall (OD-15's reversal condition, now satisfied). A stale file is
safe to keep only because it can no longer be silently authoritative.
Absent and sentinel are kept as DIFFERENT outcomes: an absent section
takes its documented neutral default, a consumed section holding
`# UNRESOLVED:` DEGRADES and is never shape-validated as a value. A
default is safe exactly where the field was never needed and unsafe
where the writer looked and could not tell.
★ THE FROZEN FIXTURE CAUGHT A REAL DEFECT, and it is why AC-3.1 exists.
The first draft added `- **Tracker**:` to setup-task's `### Traceability`
template, which altered github-status-lines.txt -- one added line. But
14.2 says the GitHub path emits NO tracker status line at all, so an
unconditional template line was wrong on the merits, not merely
inconvenient. The rendering rule now lives in the preamble, conditional
on a non-github provider; the template is byte-unchanged and the frozen
fixture is byte-identical (cmp-verified). A GitHub user's rendered output
is unchanged, which is the whole of AC-3.1.
Two further defects my own text introduced, both caught by existing
guards and both fixed at the source rather than by widening the guard:
a `gh` code span in cross-cutting text (P2-S4 forbids naming the CLI in
provider-independent prose) and a mid-line `## Operation:
learn-conventions` literal, which the op-roster scan read as an
operation named with a trailing backtick and which broke three unrelated
op-scoped guards.
BYTE BUDGET [DR-13]. Measured: git.md 55_664 -> 58_776, preamble 29 ->
34 lines, worst-case spawn 77_719 -> 80_831.
- PREAMBLE_MAX_LINES STAYS AT 40. 14.10 proposed raising it to 70 as
"the honest number"; the re-derivation says 34. A `<= 70` assertion
would be strictly weaker than the one already in place and would buy
nothing, so it is not added.
- BUDGET_GIT_MD_P3 = 58_870 is a NEW ceiling entry, not a raise:
55_750 + the MEASURED 3_120 preamble growth, headroom 94. The
Phase-2 constant stays pinned and becomes the declared base.
- ★ The revision is spendable on the preamble ONLY, mechanically: the
portion of git.md outside the preamble is BYTE-IDENTICAL across this
change (52_279 ch both sides), so a companion gate holds it to the
UNRAISED Phase-2 allowance (BUDGET_GIT_MD - PREAMBLE_CHARS_P2), with
Phase 2's own 86 ch of headroom. Growth in an operation section
still goes red against Phase 2's number.
- BUDGET_LOADED_SET_P3 is COMPUTED, not typed: the Phase-0 total plus
the git.md revision and nothing else. It carries no literal, so it
is unregisterable and unwalkable -- budget-git-md-p3 is the single
ratcheted number governing both gates.
DEVIATION TO REVIEW: 14.10 says "only the git.md component is further
revised", and BUDGET_LOADED_SET contains git.md, so the plan's
arithmetic could not hold both. [DR-13(c)]'s _resolution.md escape was
measured and rejected -- moving text into a per-op-summed reference is
NET ZERO on that gate, and the only classification that would reduce it
treats a containment control as an optional load (PF-027).
schema-scope.test.ts ships the reader half's guards: the [DR-21]
two-sided heading equality (both directions, distinct why-messages, both
bound to the shared oracle rather than to each other so a heading
dropped from BOTH files still fails), AC-3.16's three-way ADR-007 sweep
enumerated over all ten ops with release.md named, AC-3.18's four
negative greps, the retired headings, and the [DR-04] DEGRADED registry
in both directions.
The registry's forward arm is scoped by SUBTASK and the scoping is
asserted, not implied: half of 14.2's rows are emitted by per-provider
mechanics that land in 3b/3c, and "expected red" is indistinguishable
from a regression. A partition assertion makes DEFERRED_REASONS the only
way out of the forward arm, and a mirror arm proves each deferral is
real -- it immediately caught two rows already emitted at this boundary.
⚠ 14.2 TABLE GAP FOUND: manage-debt.md emits `DEGRADED (tech-debt
archive failed for #…)`, which appears nowhere in the canonical table.
Recorded in PRE_PHASE3_REASONS with its provenance rather than papered
over; the appendix needs the row or the literal needs retiring, and
neither is this subtask's call.
The git-agent.md golden is intentionally left red by this commit -- its
regeneration is the standalone fixture-only commit that follows [DR-03].
Refs #325
Fixture-only, standalone, no behaviour change [DR-03]. Regenerated with `npm run test:golden:update -- git-agent`; `--unfreeze` was NOT passed and `github-status-lines.txt` is byte-untouched, as 14.6 requires through Phase 3. The diff is 15 insertions / 10 deletions and every line of it is inside the `## Tracker provider resolution` … `## Comment-sink scrub (D11)` block — nothing outside the preamble moved, which is the same fact the byte budget's companion gate asserts numerically (git.md outside the preamble is byte-identical at 52_279 ch). The equality baselines move in THIS commit, with the fixture, never afterwards to clear a red assertion: GIT_AGENT_BYTES 56_075 -> 59_239 GIT_MD_CHARS 55_664 -> 58_776 GIT_MD_LINES 913 -> 918 TOTAL_CHARS 65_187 -> 68_299 TOTAL_LINES 1_218 -> 1_223 TOTAL_* are measured literals rather than a sum of the parts, so they have to be re-pinned explicitly — a `TOTAL = A + B + C` assertion against `A + B + C` restates its own definition and holds even when all three parts have drifted. Refs #325
…er agent Three amendments 3a-3 flagged, each closing a gap between the Tracker agent's prose and the session-start gate that spawns it: - Step 0 now names the claim-staleness bound (600 seconds) instead of saying only "Fresh"/"Stale". The hook classifies the same claim file with TRACKER_PROCESSING_STALE_SECS; an agent that guesses a different threshold either exits silently against a claim the hook considers stale (burning an OD-14 attempt per session) or re-claims one the hook considers fresh. Both failures are silent. tests/seams/tracker-claim-staleness.test.ts is the only place the two sides are compared: it reads the assignment out of the hook and the bold literal out of the agent, and reports an unstated bound rather than reading it as agreement. - The Environment section names the directive's third prompt field and tells the agent to prefer `Devflow directory:` over re-deriving the path. The field had no reader, which is residue under ADR-003; the two values agree today only by coincidence of spelling. - The attempt counter's format is pinned as one decimal-integer line. The gate reads it with the shell's `read` builtin and self-heals any non-digit byte to 0, so a count in another format is not a smaller count — it is no count at all, and the cap it was meant to advance stays open. Refs #325
… D11 emit gate The 3a docs sweep (P3a-S19), over the branch's final tree rather than any one commit, greped by artifact name. CLAUDE.md gains a Tracker paragraph in the same register as Compliance — selection is a manifest enum, conventions are an inferred global file, and the two are deliberately separate. Agent count 16 → 17 in the source tree comment and the shared-agent roster, with the roster now naming which two agents are hook-spawned and therefore have no _roster.mds row. tracker joins the sonnet tier in Model Strategy; the new ~/.devflow tracker files and the per-repo tracker config key join the file-tree listings; the Two-Mode Init paragraph gains the wizard-step predicate and --tracker <id>; and the D11 sentence now names both scrub modes, because a tool-call sink has no shell boundary to chain on and an instruction is not a gate. README gains a user-facing tracker bullet beside Compliance and 16 → 17. docs/cli-reference.md gains an --tracker <id> init row and an Issue Tracker section: the commands, the exact-match rule, a table of when the wizard asks, the learned-conventions file, and the attempt cap. Two notes are stated explicitly because they are the parts users get wrong — ~/.devflow/tracker.md and the per-repo override are both per-developer, not team-shared, and only two of the three commands re-arm the cap. `devflow tracker --status` does NOT re-arm: it returns before the re-arm call, which is the shipped behaviour and diverges from decision D-F's literal "--set/--status" wording. Documented as shipped; flagged for the orchestrator rather than changed here. docs/reference/platform-assumptions.md is completed. The Bash-result truncation row is no longer `# UNMEASURED`: the limit is documented upstream as BASH_MAX_OUTPUT_LENGTH (default 30,000 characters) and truncation preserves the head AND the tail while eliding the middle — which strengthens the case for the <bytes> check rather than weakening it, since a body with a hole in it still presents an intact framing line and an intact tail. Two rows 3a-3 asked for are added: the SessionStart `source` domain, and the unobservability of background- agent liveness that makes the 600-second claim bound necessary. Then the MCP surfaces table (five properties none of which can be checked from inside this repo), a capability → observable-symptom table that names its DEGRADED literals' authority instead of copying it, and the three standing prohibitions — never serialise ~/.claude.json, no wildcard mcp__* pre-approval, no --dangerously-skip-permissions. CHANGELOG gains five ### Added entries (selection and the CLI surface, the background agent and its hook gate, the preamble resolution order and mismatch guard, --emit, the tool-call contract) and one ### Changed entry: the accepted uninstall confirm-prompt regression, with its reversal condition recorded. Every entry states that GitHub users see no change. Written under the existing [Unreleased] heading only. file-organization.md is touched only where it pins the agent count. Refs #325
The tracker paragraph attributed the manifest-group-vs-config-gated placement rule to ADR-001, copying the anchor from src/cli/commands/tracker.ts's header. ADR-001 as currently rendered in .devflow/learning/decisions.md is about the feature-knowledge-v2 clean break and says nothing about feature-state placement, so the citation does not hold and CLAUDE.md is injected into every session (PF-025 — a wrong anchor there teaches every agent a wrong anchor). The distinction stands on its own merits and is now stated without an anchor. The pre-existing citations in tracker.ts's header and in CLAUDE.md's own Working Memory and proxy paragraphs are left untouched: they are outside this subtask's scope and correcting an anchor is a ledger decision, not a docs edit. Recorded in the tracker-feature knowledge base for whoever settles it. Refs #325
The provider dimension of the tracker work had no knowledge base. tracker- references owns the Phase-2 contract/mechanics split; this one owns how a provider is chosen, how conventions are inferred, and how a reader resolves and refuses. Covers the selection substrate (the strict-vs-tolerant parser split and why features.tracker must stay out of readManifest's hard-null set, the eleven init.ts edit sites including the hud-only manifest write the plan did not list, the wizard gating predicate, the three single-owner file lifecycles), the Tracker agent (claim protocol, the capability-by-description rule, the transient-vs-permanent split, the 11-section schema and its shared oracle, the write chain), hook Section 3 (five gates with their fork costs, the positive allowlist, the counter's three-state parse, and the method by which the zero-fork property was actually proven), the MCP substrate (the subdir- keyed generation gate, the two narrow name widenings, the --emit framing and exit codes, the <bytes> refusal), the reader half (resolution order, the OD-9 prohibition, the mismatch guard, why the status line lives in the preamble), the three-list DEGRADED registry including the tech-debt-archive literal's provenance, and the byte budget with the loaded-set decision 3b must make before it starts. Jira (3b) and Linear (3c) are present as named, empty slots with the contract each must satisfy, per the plan's docs-sweep partition. Records D-TRACKER-PAIR [DR-25] — the core/CLI pair mirrors the compliance.ts pair and ADR-013's split is why both names exist — and records that DR-15's restate-under-an-allowlist fallback was NOT taken. tracker-references' status paragraph said Phase 2 was not landed; it is on main as ecfc141, and the two KBs now cross-reference each other. Refs #325
Gate-2-approved decision D-F says the N=5 attempt cap is re-armed by `devflow init` AND by `devflow tracker --set/--status`, with both paths documented in cli-reference.md. Only `--set` re-armed: the `--status` branch returned after printing provenance, above the re-arm call. The decision is right and the code was the half that was wrong. `--status` is the command a user whose inference has hit the cap actually reaches for — it is how they find out nothing is being learned — so it has to be the command that hands back another five tries. Without it the only escape from the cap is deleting an undocumented dotfile by hand, and a re-arm reachable only from a provider change is unreachable for the user who is already on the provider they want. The `--status` branch now calls rearmTrackerInference before its return, with the same Result handling as the `--set` path: a failed re-arm warns and the status report is still printed (avoids PF-009). Re-arming is still the only write `--status` makes; the selection is untouched. Tests. The call-site assertion no longer counts calls across the whole file — two is a number that says nothing about where the two calls are — it splits the action body at the Set separator and pins exactly one call in each branch, so a re-arm that drifts out of either one goes red. Added behind it a behavioural case that drives the compiled CLI as a subprocess against a seeded mkdtemp HOME (PF-060), because the Commander .action() body is not unit-reachable: a counter written at the cap, `tracker --status`, then the counter asserted gone and the manifest provider asserted unchanged. The counter is read back before the run so its absence afterwards cannot be the state the temp dir started in (PF-018). Docs. cli-reference.md's re-arm table keeps its three rows and flips the `--status` row to Yes; the usage block's `--status` comment names the re-arm, since that is the first place a reader looks. CLAUDE.md's tracker paragraph and the tracker-feature knowledge base now name all three re-arm paths and record why an inspection command carries a write. Refs #325
src/cli/commands/tracker.ts's header attributed the manifest-group-vs-config- gated placement rule to ADR-001. ADR-001 as rendered in .devflow/learning/decisions.md is about the feature-knowledge-v2 clean break — deleting the old-install cleanup machinery — and its body says nothing about the manifest or the feature config being the source of truth for feature state. The citation does not hold. Per PF-065 a citation is an assertion about another document's contents, not a tag, and a real anchor attached to a claim its body does not make is worse than silence: nothing verifies it, it reads as researched, and the next writer copies it. This header was the origin of the copy that CLAUDE.md's tracker paragraph carried until c3626e7. The rule itself is real and load-bearing, so only the anchor goes: the header states the manifest's authority in plain words with no ledger reference. The knowledge base entry that flagged the anchor as unverified now records the settled end state — the distinction stands on its own merits, and no ADR records it. Scoped to the tracker sources this phase introduced. CLAUDE.md's Working Memory and proxy paragraphs cite ADR-001 the same way and are older than this branch; per PF-065 that sweep is per-hit classification, not find-and-replace, and it is reported to the orchestrator rather than folded in here. Refs #325
Ten generated references under `references/tracker/jira/`, one per tracker
operation, and the registration that opens the tool-call contract's generation
gate. Registering `_jira.mds` with `subdir: 'tracker/jira'` is the only edit
that opens it — there is no flag and no second declaration.
Both provider rows now read ONE exported roster (`TRACKER_OPS`), so file-set
parity across providers is a compile-time property rather than two hand-listed
arrays that have to keep agreeing. `TRACKER_GITHUB_OPS` stays as a named alias
for the guards that mean "the GitHub path's ops" rather than "the roster".
Mechanics: tool calls only, selected by capability description; `32767` body
cap; `Retry-After` honoured verbatim with no pre-emptive rung, because Jira
publishes no remaining-request count; a single-query batch fetch; a four-rung
dedup ladder whose marker is the comment's FIRST LINE, namespaced per comment
kind so the three kinds cannot suppress each other; a stated aggregate call
budget; and a `### Posting gate` in each posting op that NAMES the tool-call
contract without restating it.
Four findings the registration surfaced, fixed here rather than worked around:
- `planOverlayUnits` bucketed `tracker/_mcp.md` as a provider directory named
`tracker`, so the unit's atomic swap was a rename of `tracker/` itself over
every provider directory beside it, with a staging sibling outside the
subtree the prune converges. D-OVERLAY-PROVIDER-SHAPE classifies by shape;
everything else is a flat set carrying the directory it lands in.
- The overlay suite's stale-prune arms used `jira` as a provider the manifest
does not list. It became one, inverting those arms silently. The fixture
provider is now `probe-provider`, and a guard asserts it stays unreal.
- The DEGRADED registry admitted only template spellings, while the appendix
fixes the per-provider cell as the instantiated form. Both are now admitted,
with `{provider}` tokens derived from the registry and `{capability}` tokens
from the contract's own table, so both vocabularies stay closed.
- The reason collector captured a hard-wrapped reason with its newline intact,
matching no registry entry and describing no defect. It collapses whitespace.
`_mcp.mds` gains two prose corrections its own consumers now depend on: Bash
results preserve the head and tail and elide the middle, which makes the
`<bytes>` check more necessary rather than less, and the file that names this
contract is a per-operation mechanics file, not the always-loaded preamble.
Refs #325
The GitHub-scoped loaded-set row answers "what does a tracker spawn cost on the GitHub path?", and this phase does not change that answer: no GitHub mechanics file names the tool-call contract, so its term stays 0 by construction and the row still measures 80,831 against its unraised 80,944. A provider that DOES load the contract is therefore priced on its own row, `BUDGET_LOADED_SET_JIRA = 88_660` — measured 88,609, headroom 51. Folding it into the GitHub number would have billed every GitHub user for bytes they never receive, and would have done it by raising a ratcheted ceiling. Three arms keep the row from being a free number: the delta over the GitHub ceiling is held to what this provider actually adds (the contract, plus the difference between the two providers' per-op terms); the ceiling must still sit above the measurement it was derived from; and a named arm fails any registered tool-call provider that has no ceiling of its own, which is the shape the next provider must satisfy. The four-shape table gains a row per provider, the contract's own size as a recorded row, and a derived shape count. The gate went red once during authoring — a 197-character rewrite of the contract's truncation clause breached it — and the clause was condensed back to 47 characters of growth rather than the ceiling being moved. That is the response the failure message prescribes, recorded so it is the precedent. Refs #325
Fills the named `## Provider: Jira (3b)` slot: why parity is structural rather than asserted, the provider facts and the ones that must be absent, the dedup ladder and the first-line namespaced markers, the aggregate call budget as a product, the single-query batch and the two shape classes its guard forbids, where the query-safety rule lives and why it lives in exactly one operation, the ref grammar and how AC-3.4 falls out of it, and the loaded-set table with its new ceiling. Three other sections moved to their end state rather than being left describing a tree that no longer exists: the Overview status line, the DEGRADED registry (the deferred half is now empty, and two placeholder vocabularies are derived rather than listed), and the byte-budget decision, which is now a shipped fact. The 3b/3c handoff list is rewritten as what registering a provider actually drags with it — eleven numbered rows, each stated as the end state this commit reached, so the next provider can read it as a checklist. Two of the rows are findings rather than plan items: the installer's overlay unit shape, and the overlay suite's fixture provider that stopped being fictional. Refs #325
One `### Added` entry under the existing `## [Unreleased]`. No version header is added or touched. Covers what a Jira user gets and, as deliberately, what they do not: the provider facts that are present (`32767`, `Retry-After` honoured verbatim) and GitHub's that are absent, the single-query batch, the four-rung dedup ladder and why the marker is the comment's first line, the per-kind namespaces, the call budget as a product, the pointer sentence that replaces a collapsed block, and the rule that Jira being unreachable still cuts the branch and opens the PR and never falls back to creating a GitHub issue. It closes on the guarantee that matters to everyone else: GitHub users see nothing change. Refs #325
Registers `src/assets/mds/tracker/_linear.mds` against `tracker/linear`, reading the same exported `TRACKER_OPS` roster as the other two providers, so file-set parity across three providers stays a compile-time property. The provider ships at rank 4 (OD-12) and says so: a stock official server exposes no viewer/"me" tool and its attachment create takes a binary payload, so three of the dedup ladder's four rungs are unreachable. The mechanics therefore post with a warning, bind the marker to the comment's first line, and carry a second discriminator a coincidence would not reproduce. The borrowed `32767` cap and the rank are recorded in a module-level `## Known Unknowns` section with the filed probe issue (#343); the section sits above the first section marker, so the build emits it nowhere and no generated reference acquires a column-0 `## ` that would truncate its own op section (PF-063). Registration drags eleven things with it, and they land here because the guards they move go red the moment the provider is registered: - both manifest floors 24 -> 34 together (install and tarball sinks) - `capability-hoist-block-floor` 39 -> 49 - `PROVIDER_OWNED_PATHS` gains two entries, per (path, token) - a NEW `budget-loaded-set-linear` ceiling (91_000, measured 90_951) with its derivation; the two per-provider budget gates are now generated from one `PRICED_PROVIDERS` table rather than written out per provider - `unsupported by linear` admitted by the DEGRADED registry's derived tokens - the deferral probe drops every gated sub-directory rather than naming one - the parity scan is now over the registry's provider rows, flipped to three columns with a matrix-cell arm (AC-3.8, §8.11) The per-item fetch shape table moves to tests/helpers.ts: the same [DR-08] claim is made per provider and across providers, and two copies of the table would be two authorities on what a per-item fetch looks like.
AC-3.13 as a MATRIX rather than three per-provider pins. Every claim in it is a claim about the difference between providers, and a difference cannot be asserted from inside one of its sides: `32767` present in a tool-call module is a weak fact, while `32767` present in both tool-call providers and absent from GitHub is what §14.2 actually resolves. Five rows, each naming which providers state it and why it is a provider fact: `60000` and `X-RateLimit-Remaining` GitHub-only, `32767` on both tool-call providers, `Retry-After` Jira-only, `RATELIMITED` Linear-only. The absence half is pinned as hard as the presence half — a module naming another provider's signal has copied a backpressure model across a boundary where it does not hold — and the `present` list shape puts every unlisted provider in the absent set by construction. Both sides are read. A source-only pin is satisfied by a literal in module-level prose, which the build emits nowhere, and `_linear.mds`'s `## Known Unknowns` names the borrowed cap exactly there. [DR-08] rides along as the cross-provider batch negative, driven by the shared shape table, plus the positive bound arm scoped to the tool-call providers: a `gh` invocation takes a list the always-loaded step already trimmed, while a composed query must carry the bound itself.
…chanics
[DR-19]'s remaining arm. The three cross-cutting documents already have a
shared-literal registry; `tracker/_mcp.md` sits in the identical position and
had none, so the rule its own prose states — a per-operation file may INVOKE a
rule here and never restate its substance, and on any conflict the contract
wins — was unenforced for exactly the twenty provider files written from
scratch in 3b and 3c. The containment oracle cannot see it: it accounts for
lines that existed pre-split, not for a newly authored restatement.
A SEPARATE registry rather than rows added to the sibling one, because that
registry's ownership arm asserts its owners are exactly GIT_CROSS_CUTTING_DOCS
and the contract is a different module kind behind a different gate. Folding it
in would have meant relaxing that arm to admit a fourth owner instead of
classifying the case (ADR-025).
Five entries, each justified: the framing line's composition, the definition of
where the gated bytes come from, the transformation prohibition, the
selection-by-description rule, and a capability-table row. What is deliberately
NOT in it is the set of literals every posting mechanic must name — `D11-OK`,
`<bytes>`, `SCRUB: N`, `SECRET-EXPOSED` — and a named arm asserts their absence
from the registry AND their presence in every posting mechanic, so this guard
and the bypass guard cannot be satisfied only one at a time.
Three arms plus two probes: the gate-is-open subject check, the positive arm
over the contract and its three siblings, the negative arm over every
registered provider's tree (asserted by provenance, not by a count), and a
seeded restatement of the `{SCRUBBED_BODY}` rule driven through the same
collector — with every other entry asserted unreported over the same corpus, so
the probe proves the collector discriminates rather than matching anything.
…der-neutrally
Two GitHub shapes left in provider-blind, always-loaded positions. Phase 2
reserved both for Phase 3 because neither could be decided with one provider
registered; with three they are wrong for two of them.
**The entry gate.** `backlink-shipped-issues` step 0 required every
`SHIPPED_ISSUES` entry to be "digits only" — which made the operation
unreachable under jira and linear, since `PROJ-1` is not digits and the step is
the gate every entry passes. It now defers to the resolved provider's anchored
reference grammar, stated and enforced by that provider's mechanics, and the
github grammar `^#?[1-9][0-9]{0,8}$` MOVED INTO the github reference with the
same drop rule and the canonical per-ref DEGRADED reason. The gate is not
weakened, it is relocated to where enforcement lives: the operation always
loads its provider reference, and an absent reference already means
`tracker mechanics unavailable` with no tracker call, so the fail-closed
property holds. The metacharacter rationale went with it — all three provider
mechanics now say the anchored form is what keeps a reference out of a query or
a command, and git.md's input contract already states the
shape-gate-at-the-sink rule once.
**The `## Operations` table.** "Fetch GitHub issue", "Fetch multiple GitHub
issues" and "Create or enrich a GitHub issue" are tracker operations whose
provider is resolved per spawn. Rewritten to "tracker issue(s)", together with
the two op-body descriptions that duplicate those cells — left alone the table
would have contradicted the operation two hundred lines below it. GitHub
literals that are NOT a tracker fact are untouched: `create-release` and
`fetch-review-threads` name GitHub because PR and release hosting stay there
under every provider.
Net effect on the always-loaded file: 58,776 -> 58,751 characters and one line
shorter, so every budget row gained headroom rather than spending it. Six
baseline lines are booked in CONTAINMENT_EXEMPTIONS with their rationales —
these are rewrites, not moves.
`tests/tracker/hostile-values.test.ts` gains the two describes it has been
carrying a deferral note for, because this commit is what gave them a subject:
`refs per provider` needed an anchored grammar for EVERY provider, and GitHub's
only exists in the mechanics from here on. Register row 25's eight payloads are
driven against all three grammars, each pinned form asserted verbatim in that
provider's shipped tree, with an unanchored-alternation probe showing what
§14.1's "never `^A|B$`" is about. The JQL/filter describe models §14.9-10's
escape-then-drop rule as written and scopes the drop claim to the four
characters that can end a quoted literal — a query sink is not a shell, and
dropping inert text would cost search results while protecting nothing.
The git-agent golden goes red here and is regenerated in the next commit
alone [DR-03]. `github-status-lines.txt` is byte-identical to its Phase-0
capture: every edit is outside the ranges it samples.
… wording Fixture-only, reviewed as a text diff, no behaviour change in it [DR-03]. This is the Phase-3 regeneration §14.6 allows once, and the diff is exactly the six text moves the previous commit made: three `## Operations` cells, two op descriptions, and `backlink-shipped-issues` step 0 losing its digits-only clause and its now-duplicated metacharacter sentence. The three equality baselines against this fixture move with it, in this commit, because that is what makes them baselines rather than budgets — re-pinning one later is how it stops being evidence of anything: `GIT_AGENT_BYTES` 59_239 -> 59_214, `GIT_MD_CHARS` 58_776 -> 58_751, `GIT_MD_LINES` 918 -> 917, and the two `TOTAL_*` preloaded-set sums. `tests/fixtures/golden/github-status-lines.txt` is untouched and `cmp`-identical to `main`: the ranges it samples are the per-issue steps inside the generated references and the D4/D11 blocks, none of which this change reaches.
The two facts the Linear mechanics inherit rather than measure, written for the person they affect rather than for a reviewer of the module: the comment cap is borrowed from Jira, and on a stock workspace devflow cannot ask the tracker who it is, so it posts a back-link with a warning instead of suppressing one it cannot verify. Both outcomes are described by what the user sees — a truncation with a pointer, or a DEGRADED line on every run — and the filed probe issue is linked so the borrowed number has a visible owner. Placed inside `## Issue Tracker`, after the attempt cap, because a reader who has just selected `linear` is exactly who needs it. No transport acronym: this is a page a user reads, and which protocol the tracker is reached over is not something they can act on.
…er wording `## Provider: Linear (3c)` filled, and the two KBs moved to their end state rather than gaining a third forward-looking slot. **tracker-feature.** The Linear section states OD-12 rung by rung — three of the four dedup rungs are unreachable on a stock server, and each one's reason is written down because an unexplained rank reads as a misconfiguration. It records the marker's two halves (first-line binding against a quoter, project URL against a coincidence) and why the URL is part of the MARKER rather than a footer; the `400 RATELIMITED` signal and why the code has to be named against a status-shaped D4 rule; the two separately anchored ref forms; the call budget as the common path rather than the edge; the loaded-set row with 47 ch of headroom and why this provider's max_op is the largest of the three; and why `## Known Unknowns` is module-level prose the build emits nowhere (PF-063). The `3b / 3c handoff contract` is now `what registering a provider drags with it` — the checklist a FOURTH provider reads. Rows moved to their end state: five reference modules and 19 discovered hosts, floors at 34/34/49, owned paths two per provider, all four hostile-value describes live, and a new row 12 for the `_mcp.md` shared-literal registry with the distinction that keeps it from fighting the bypass guard. The Phase-2 reservation is replaced by the decision it was waiting for, including the `X-RateLimit-Remaining` threshold KEPT in `SKILL.md` with its reason — so that literal's survival reads as a decision. Two gotchas earned the hard way: the bypass regex reads any `word:` as an argument assignment, so prose like "read the text: a rule…" is reported as an ungated posting mechanic (write mechanics, not prose that looks like a call); and a provider's `max_op` is what the budget prices, so a cross-cutting paragraph costs a ceiling only in whichever op file is already the biggest. **tracker-references.** The `## Operations` table reservation and the DR-19 MCP deferral are both resolved, and the AC-2.7 reachability row is re-stated at 34 files with the template instantiated per registered provider. It also records where the github grammar was placed and why: above the range `STATUS_LINE_REFERENCE_FILES` samples in that file, which is what kept the frozen fixture byte-identical.
Three `### Added` entries under the existing `## [Unreleased]`, written for the user rather than for a reviewer of the module. The first is what Linear can do and, deliberately at equal length, what it cannot: the rank-4 dedup reality stated as an outcome the user sees — a DEGRADED line on every run and a possible duplicate back-link — rather than as a capability table. The second is the borrowed cap and the filed probe issue, because a number adopted from another provider and presented as a measurement is the thing a changelog should not do. The third is the provider-neutral wording and the digits-only entry gate, and it names the gate as a live defect that made the operation unreachable under two of three providers rather than as a tidy-up. No version header touched.
A leftover draft paragraph restated the surviving comment on the same line, describing one assignment twice. No behavior change.
…tStagingTree subdir was computed twice (sourceDir derivation, then the relPath ternary) for the same unit on every loop iteration. One local reused at both sites. No behavior change.
…te site
init.ts's HUD-only manifest write and tests/helpers.ts's makeManifest default
both spelled { provider: 'github' } while DEFAULT_TRACKER_PROVIDER — documented
as the value every malformed input self-heals to, and already used by
init-seed.ts for the same default — sat unimported. The literal typechecks
because it is a member of the domain, which is exactly why a moved default would
pass both sites unnoticed.
Issues: typescript-10 (folds consistency-05, architecture-13)
provider-scope's header claimed FOUR negatives, each "a NAMED collector with a known-bad probe", over five describes of which one — no generated GitHub mechanics file names `_mcp.md` — was an inline text.includes with no collector and no seed. The header now names five and says how each is driven, and that arm gets collectOpsNamingContract plus a probe that seeds one op and shows the collector reports it, so its empty result is evidence rather than a matcher that recognises nothing. Its opening line describes the property the file enforces today instead of a phase. mcp-sink-bypass said FOUR CLAUSES at four sites over a five-row CONTRACT_CLAUSES whose probe already asserts `>= 5`; the prose now reads the registry rather than counting it, and names the `SCRUB: N` echo the enumeration had dropped. literal-agent-paths asked for "a build"; requireBuiltCli refuses a STALE dist/cli.js as loudly as an absent one, so the header and the GREEN arm now say a CURRENT build and name that arm as the staleness canary. Three `.find(...)!` sites become requireForeignToken / requireCorpusEntry, which name the token or the file that vanished. testing-16, typescript-14 applies ADR-003, avoids PF-018, avoids PF-064
D-TRACKER-OWNER said "devflow init and devflow tracker --set each call them
exactly once" while rearmTrackerInference has a third caller — devflow tracker
--status (D-F), documented correctly in that function's own docstring — so the
module header and a function inside it disagreed. It now separates the two
owners: the sentinel has two callers, the counter three.
promoteCrossCuttingUnit still asserted "these documents land directly in
references/" immediately above the destDir line that made it false; it now names
references/{unit.dir}, which is the references root when dir is empty.
Issues: architecture-08
applies ADR-003, avoids PF-025
The Phase-2 git.md ceiling was introduced by "no longer the live gate" in both byte-budget.test.ts and its manifest entry — narration of a change rather than the fact a reader needs. Both now say what is true: the Phase-2 value is the DECLARED BASE the live gate (budget-git-md-p3) is re-derived from, and it stays pinned because a ceiling is only ever re-derived downward. No ceiling value moves. GITHUB_API_MD_CHARS' rationale narrated two superseded measurements (17,259 and 17,539) against a constant that is neither, so the prose could not tell a reader whether the current value was the expected landing or a fourth unrecorded one. It now states why the excluded term needs an anchor at all and leaves the assertion as the only figure (PF-057), and drops the batch-plan narration of edits still to come. measureOptional's docblock named two of the three rows its caller passes; decision-markers.md is now named with them. testing-16, documentation-05 applies ADR-003, avoids PF-057
schema-scope's non-vacuity arm re-asserted `>= 11` on the shared oracle, which schema-oracle.test.ts now enforces at the oracle's construction as exactly TRACKER_SCHEMA_SECTION_COUNT DISTINCT headings — the redundant assertion goes. The two remaining floors count distinct headings instead of raw ones: the reader block legitimately spells `## Reference Rendering` twice, so the raw count carried a heading of slack and a list that dropped one section and repeated another cleared it. mds-variants' docblock called the shipped registry GitHub-only over a registry carrying github, jira and linear; it now states what scenario 6 asserts. The contract-gate narration and its `★` arm described which phase would turn the gate on — the gate is derived from the registry, so the prose says that instead. `.find(...)!` / `.get(...)!` / `harvestFence(...)!` in the registry arm and the command→agent seam become requireHarvest, requireOpSection and a bumpFence counter, each naming the row, op or agent that went missing. testing-16, typescript-14 applies ADR-003, avoids PF-018
The Tracker agent stages its scrubbed file as ~/.devflow/.tracker-staged.XXXXXX and removes it from a trap on EXIT INT TERM. A SIGKILL outruns the trap, so a stage outlives its run — and the artifacts-only sweep removes exact paths, so it walked past every orphan while reporting the directory swept. installArtifactPaths now carries the prefix as a prefix entry, and resolveInstallArtifactPaths is the one place a prefix becomes paths: the removal loop and the dry-run preview read the same resolver, so a path removed without being previewed is unreachable. The stage is an install artifact, not user content — it is a scrubbed copy that was never placed — so @d8 gains the matching check that no user-content name falls UNDER a prefix entry. TRACKER_STAGED_PREFIX is cross-pinned against the agent's mktemp template, the way the other basenames are pinned against the hook. Issues: HANDOVER from batch A1 avoids PF-013, PF-018, PF-025
Each of the 28 parity rows spawned the driver through execFileSync, which holds the event loop for its whole duration — a suite of synchronous spawns runs end to end however it is scheduled. The rows already stage their own `case-N` devflow directory, so the fixtures were concurrency-safe already; only the spawn was not. The reader returns a promise and the describe is concurrent. Measured over three interleaved rounds on one machine: 4169/4135/4125 ms serial against 3736/3813/3607 ms concurrent, and 4.53 s for the original synchronous file. The async reader brings a vacuity mode with it, so the row asserts the token is a string: with the `await` removed, 48 of 52 assertions stayed GREEN before that guard and 28 of 28 parity rows fail with it. A second probe collapsing every row onto one shared directory turns rows red, so the per-row staging is load-bearing rather than decorative. partially resolves performance-02 avoids PF-018
ADR-019's body is the typed Claude Code flag registry; it carries no one-definition-seam corollary about prompt DI seams, so citing it as the authority for this module's import discipline asserted a claim the anchor does not make. The engineering fact stands on its own. Issue: consistency-04 avoids PF-065
ADR-003 has two numbered corollaries and no enumerated clauses, so "clause iii" indexed nothing — the anchor is right, the sub-clause was not. The §14.3 heading test's >= 11 floor is redundant: the count and the distinctness are settled at the oracle's construction and driven over every admitting mutation by tests/tracker/schema-oracle.test.ts, so the equality is the whole claim here. No mechanics claim names an empty op list, so the collector's "anywhere in the tree" branch could not run. The op list is now a non-empty tuple: an empty one would report nothing while reading no provider bytes at all, and the type refuses to spell it rather than a branch having to notice it. Issues: consistency-04, M2 handovers (a) and (b) avoids PF-065, avoids PF-018, applies ADR-003
resolveTrackerInitState is exported and pure and had no test at all: the --tracker flag's only gate before a machine-wide provider change was covered by a regex count of its call site. Driven by calling it — every registry ID, github as a real override (D-E), and a near-miss whose rejection message is compared against the strict parser's own rather than re-typed. The wizard gate's mode argument is pinned too. runTrackerStepAt must forward its caller's mode; a hardcoded 'advanced' returns true on every TTY, so a user who chose Recommended is asked a question that path never asks — and the 'recommended' call site still reads correctly, so nothing there shows it. Both rows were shown red against seeded mutations before landing. Issue: testing-04 avoids PF-018, avoids PF-029
The Set branch drives four owners in one order — rename, persist, re-arm, sentinel — and every one of them was covered by a regex over the source. Run as a subprocess against a seeded mkdtemp HOME, mirroring the --status arm, and asserting the whole end-state of the devflow dir in both sentinel directions plus the rejected-ID arm that must touch nothing. Each owner was dropped in turn and each arm shown red before landing. Issue: testing-04 avoids PF-018, applies PF-015, PF-060 (isolated HOME)
The tracker selection lifecycle had no executable coverage and .tracker.enabled appeared in no e2e file at all. Three arms on the existing isolated-HOME harness: T1 -- a real jira install plus init --reset: the conventions file moves aside, the sentinel is removed and the manifest collapses to github. The rename only fires because the lifecycle is handed the real prior manifest rather than the --reset-gated seed, which the reviewer focus area claimed was covered. T2 -- the manifest path made a directory so the atomic write genuinely fails: init still exits 0, warns, and converges no tracker artifact. T3 -- a prior linear selection survives a --hud-only install, asserted by running init rather than re-evaluating the ?? locally. Each arm was shown red against the corresponding seeded mutation. Issue: testing-04 avoids PF-018, applies PF-015, PF-060 (isolated HOME)
vitest parallelises across files, not within one, so the session-start-context Section 3 arms held tests/shell-hooks.test.ts on the suite's critical path. Running them as a sibling file drops the pair's reported duration from 26.35s / 25.46s to 15.55s / 17.00s over two runs each, with no in-file concurrency. runHook and HOOKS_DIR become tests/shell-hooks-helpers.ts so both files drive hooks through one implementation. The moved block is byte-identical and the pair still reports 306 tests. Two mutations (a spawn arm's model= expectation, a source-reading arm's staleness literal) were confirmed RED in the new file and restored, so the arms are executing rather than passing vacuously — avoids PF-018. numeric-floors.json's tracker-section-max-chars entry follows its pattern to the new file; floor, pattern and occurrences are untouched. applies ADR-003 Issue: performance-02
This was referenced Sep 17, 2026
collectMissingMechanicsClaims never read corpus.tree — only label, vocab, and read. jiraTree() had exactly one caller (the JIRA_CORPUS literal) and is now unreachable; linearTree() keeps its other callers. Update the ProviderCorpus docblock to describe its actual three members.
ADR-003 and ADR-019 are prose decisions with no "clause iii" or numbered corollary structure — those labels were copied between neighbouring comments rather than read from the ledger. Per PF-065 this is per-hit classification, not find-and-replace: - mds-variants.test.ts (x2), dist-agents.test.ts:499: ADR-003 citations whose claims the decision body actually supports — keep the anchor, drop the fabricated "clause iii" label. - dist-agents.test.ts:384: bare "(clause iii)" names no anchor and its claim (reviewed accretion, not end-state residue) isn't ADR-003's subject — dropped outright. - prompt-io.ts, compliance-prompts.ts, attribution-prompts.ts, build.test.ts: ADR-019's body is the typed flag registry with no one-definition-seam or source-vs-compiled corollary — stated the engineering fact plainly instead of the borrowed anchor.
The 600s literal it names moved with tracker Section 3 into tests/shell-hooks-tracker.test.ts; the comment still named the old tests/shell-hooks.test.ts.
Import the shared runHook from shell-hooks-helpers.ts instead of keeping a byte-identical local copy with a stale "mirrors shell-hooks.test.ts:1495" comment (that file's Section 3 moved to shell-hooks-tracker.test.ts).
…ation The assertion message led with "GAP-17: four sections originally had no stated default" — a fixed finding ID from the fix that no longer describes the current schema table. State the invariant the assertion actually checks.
budget-git-md-p3, budget-loaded-set-jira and budget-loaded-set-linear described pre-resolve measurements (58_782/68_305/88_615/90_984 and headrooms 88/45/16) that no longer match the tree. Re-measured from tests/tracker/byte-budget.test.ts's printed table (never hand-derived, per PF-057) and rewritten to state the formula in words plus the current figure, dropping the hand-summed intermediate breakdown so the fixture doesn't carry a second, independently-drifting record of one measurement. Also drops linear's now-false "thinnest of the three rows" claim — jira is currently tighter (headroom 27 vs 39) — in favour of the evergreen rule: whichever provider row has the least headroom is the binding constraint, so check both. ceiling/pattern/occurrences unchanged.
…ation
The _HAS_JQ=false parity arm spawns the hook 8 times serially, re-seeding
one HOME between shapes — the ordering IS the assertion, so it cannot be
parallelised. It measures ~1.45s standalone but timed out against the
suite's 5s default once the file began running alongside 148 others.
Give it an explicit 20s per-test timeout. The assertions are untouched,
and discrimination was re-proved rather than assumed: mutating the node
fallback's default branch in json-helper.cjs (get-field-file emitting
'jira' in place of the caller's default) takes the arm RED on the
absent-key shape with "node backend emitted for {}". avoids PF-018
…sured duration The ISSUE_PR_LINK forwarding arm calls buildCommittedTree() — a copy of the committed sources built into a temp root — and pays the whole cost, because the helper memoises the build and this arm is the first of the two callers to claim it. It measures 3.72s standalone but timed out against the suite's 5s default five times once the file ran alongside 148 others. Give it an explicit 20s per-test timeout (4x measured is 14.9s; floored at 20s for contention headroom). The assertions are untouched, the build is unchanged, and nothing is cached across tests. avoids PF-018 The sibling probe arm awaits the same memoised promise but measures 1ms — tests within a file run serially in declaration order, so it never pays the build.
Owner
Author
Resolution SummaryFull summary withheld (public repository).
Full report: .devflow/docs/reviews/feat-325-tracker-phase-3-init-selected-provider/2026-09-17_1253/resolution-summary.md (not committed; ask the author) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem Being Solved
Phases 0–2 left a provider-shaped hole with one provider in it. Devflow users whose issues live in Jira or Linear got GitHub-only traceability: issue creation,
Closesrefs, back-links, tech-debt tracking and release evidence all assumedgh. Selecting another tracker was not possible, and the one always-loaded gate that every shipped-issue back-link passed through required issue references to be digits only — so even a hypothetical Jira user would have had the operation silently drop everyPROJ-1.This phase fills the hole with two things that are deliberately never conflated: selection (a manifest enum read by a hook and a prompt preamble — nothing infers it) and conventions (a global file inferred once by a background agent, existing only for a non-GitHub user).
Key Changes
3a — selection substrate, the agent, the hook, the tool-call substrate.
src/core/tracker.ts(registry, strictparseTrackerId, tolerantnormalizeTrackerFeature, therearmTrackerInference/applyTrackerSentinel/renameStaleTrackerConventionslifecycle trio) +tracker-prompts.ts+tracker.tsCLI;init.tswiring on both wizard paths behind one shared predicate,--tracker <id>,devflow tracker --status/--set,manifest.features.trackerwith a silent self-heal that is deliberately kept out of the hard-null set, seed/--resetgating, and uninstall classification (tracker.mdis user content; the three.tracker.*files are install artifacts, and the two lists stay disjoint).plugins.tsregistration.session-start-contextSection 3: sentinel gate, manifest read, a positivejira|linearallowlist before any interpolation, claim file,source-gating, attempt cap of 5 — and zero subprocess invocations undergithub, proven by a stub-jqinvocation counter.redact-secrets.cjs --emit(nonce framing, double scrub, exit code 5, no body on any non-zero path) +_mcp.mds, the provider-independent tool-call contract behind a registry gate + the Git-agent preamble's resolution order, mismatch guard and- **Tracker**:line.devflow tracker --statusre-arms the attempt counter. Asking why nothing is being learned is exactly the moment to hand back another five tries; both re-arm paths are documented.3b — Jira mechanics. Ten generated references, all tool calls selected by capability description rather than by tool name,
32767/Retry-After/ no pre-emptive rung, a single-query batch, the≤50 × ≤2 = ≤100aggregate budget, namespaced first-line markers, a pointer sentence where GitHub posts a collapsed block. It also surfacedD-OVERLAY-PROVIDER-SHAPE, a production defect in the installer:tracker/_mcp.mdlands directly intracker/, andplanOverlayUnitsbucketed it as a provider unit namedtracker— a unit whose atomic swap renamestracker/itself over every provider directory beside it, with a staging sibling outside the subtree the prune converges. The github files survived by promotion order alone. Classification is now by SHAPE (tracker/{provider}exactly), with the flat set carrying the directory it lands in and one staging name per directory.3c — Linear at rank 4, and the wording that was still GitHub's.
references/tracker/linear/. Rank 4 is stated rung by rung (OD-12): no viewer/"me" tool and a binary-payload attachment create on a stock official server make three of the four dedup rungs unreachable, so devflow posts with a warning —dedup unavailable — duplicate possibleon every run — and the absent identity capability is never a reason to suppress. The marker carries both halves rank 4 needs: first-line equality against a quoter, and the project URL against a coincidence.RATELIMITEDnamed with its error-text form, because the generic 4xx rule would keep the fan-out running into the window the rung exists to stop. Two separately anchored ref forms after ASCII-upper normalisation.## Known Unknownsin the module (module-level prose, emitted nowhere by design — a column-0##inside a generated reference truncates its own section), surfaced for users indocs/cli-reference.mdwith the rank-4 statement, with Probe Linear's real comment-body and rate-limit behaviour (32767 is borrowed from Jira, unmeasured) #343 as the owner and artifact for the borrowed32767.tests/provider-literals.test.ts— the 5 × 3 literal matrix over sources and generated trees, plus the cross-provider [DR-08] batch negative.MCP_SHARED_LITERAL_REGISTRY, because the sibling registry's ownership arm fixes its owners as exactly the three cross-cutting documents.## Operationstable's three tracker cells (and the two op descriptions that duplicate them) now say "tracker issue";backlink-shipped-issuesstep 0 defers to the resolved provider's anchored grammar, with GitHub's^#?[1-9][0-9]{0,8}$moved into the github mechanics beside the same drop rule. Net effect: the always-loaded prompt is 25 characters and one line shorter, so every per-spawn budget row gained headroom while gaining a provider.src/assets/skills/git/SKILL.md'sX-RateLimit-Remainingthreshold was kept, deliberately, and recorded as such.main's did, and the normalisation is what neutralises it.main's always-loaded step 0 required entries to be digits only, so#42was dropped; §14.1 fixes the github grammar as^#?[1-9][0-9]{0,8}$, which admits it — and the operation interpolates the admitted entry intogh issue view {number}andgh issue comment {number}. A#at word start opens a shell comment, so the widened set had to come with a rule: strip exactly one leading#once, before the loop, and interpolate only the stripped digits (#42≡42, the same normalisationfetch-issueandfetch-issues-batchalready state). GAP-18 calls this pre-flight the shell-injection guard for an interpolated ref, and that is the sense in which it is not relaxed — the admitted set is wider by one normalised form, and the normalised form is the only one that reaches a command. Pinned per file intests/provider-literals.test.ts(five rows, each with a per-row known-bad probe), because the tree-level matrix cannot say which file states it.#is a RENDERING, and a preamble rule says so at zero template bytes. The templates read- **Issue**: #{number},- **Number**: #{number}and### Issue #{number}:, and the first of them is line 48 of the frozengithub-status-lines.txt— so AC-3.1 makes them uneditable for the life of this phase while AC-3.11 wants- **Issue**: PROJ-123under Jira. The two criteria meet on the same bytes, and §14.1 is the arbiter:#-prefixing is a property of the github rendering ofISSUE_REF, not of the field. The resolution is therefore one bullet in## Tracker input contract— every rendered issue ref takes## Reference Rendering's form, never#-prefixed — the Output templates'#is github's rendering, not a literal — pinned as claim 6 oftests/tracker/schema-scope.test.ts, with a companion arm asserting the three slots still carry the#(if they lost it the rule would be a rule about nothing, and the frozen fixture would already have broken). The branch half of AC-3.11 ({type}/{KEY}-{slug}, type exact-matched against## Issue Types) is deliberately not restated in the always-loaded file — the byte budget refuses a second copy and GAP-37 forbids one — and is pinned per provider instead.2bf24b3(3a's preamble),bc36a48(3c's neutral wording),5db2639(3c's reference wording),9e8cd83(the alignment fixes) andf131c5c(scoping the rendering rule to issue refs).github-status-lines.txtis untouched in all five, verified bycmpagainstmain.Breaking Changes
None for GitHub users (AC-3.1, AC-3.12). No prompt, no new file, no altered byte on the GitHub path; under provider
githubthe new session-start gate forks nothing, and the frozen status-line fixture is byte-identical to its Phase-0 capture.One deliberate UX change. A jira/linear user with no other user content now sees a confirm prompt on
devflow uninstallwhere they previously got a silentartifacts-onlysweep — because~/.devflow/tracker.mdis classified as user content. Documented inCHANGELOG.md### Changed. The reversal condition is recorded at the code site and in the feature KB: that classification is safe only because the reader-side mismatch guard removes the silence a stale file would otherwise have. If that guard is ever dropped or softened,tracker.mdmoves back toinstallArtifactPathsin the same change.Reviewer Focus Areas
init.tsblast radius — both wizard paths behind one shared predicate, re-init covered, and the manifest's hard-null set untouched.redact-secrets.cjsboundary amendment — the normative single-stdout block amended rather than bypassed; exactly two stdout write sites; "no body on any non-zero exit" proven on every path; theparseArgs/frameEmit/scrubTwicedecomposition present; the framing carries the first-pass<n> [type:count,…], and_mcp.mdmandates bothSECRET-EXPOSEDand the<bytes>check.caseruns before any interpolation and is a positive allowlist (jira|linear), never!= github; the silence clause is byte-identical to Section 2's; the GitHub path forks zero subprocesses.>= 50subagent_typefloor.dist/agents/git.md,dist/commands/*.mdorreferences/tracker/github/**, and the frozengithub-status-lines.txtis byte-identical.## Known Unknowns, and the filed probe issue with a named owner (Probe Linear's real comment-body and rate-limit behaviour (32767 is borrowed from Jira, unmeasured) #343).D-OVERLAY-PROVIDER-SHAPE— production code outside the "prompt assets + tests" domain of the subtask that fixed it, taken because the manifest that subtask shipped would otherwise have made the install delete provider directories. Review theOverlayUnitReftype change (its cross-cutting arm gaineddir) and the one-staging-name-per-directory rule.Test Plan
Suite: 144 files / 5,174 tests, all passing — 143 / 5,095 in the main run plus
eager-memory-refresh(1 / 79) run separately, because it spawns realclaudesessions and its wall time swamps the signal. Up from 141 / 5,009 at the phase branch point.npm run buildgreen (MDS 19 compiled, 0 deferred, 0 errors, 0 warnings),npm pack --dry-runcarries all 34 generated references and every shipped.mdssource.RED-first evidence (R1). Every guard in this phase was observed red before its subject existed or its claim became true, and each one's failure text is recorded in the branch's handoff notes. The 3c highlights:
linear-module.test.tsagainst the pre-module tree: fail-loud at module scope, 0 tests collected, not a silent skip.expected [] to deeply equal [ '…/_mcp.mds' ]— with two tool-call providers, dropping the first left the second holding the gate open), the provider-subdir set equality, the bypass regex firing on prose (text: a status-shaped rule…),PROVIDER_OWNED_PATHSreporting 45 sites, the every-provider-has-a-ceiling arm naminglinear, andreasonSpellingsgainingunsupported by linear.git.mdneutralisation breached the Jira row (51 ch of headroom, +54 needed) — the response was to condense the clause, never to move a ceiling.AC-3.17's pinning is prompt-text assertions + the claim-staleness seam + hook-side fresh-claim suppression — not a concurrency test. The plan's AC table says "concurrency test" and nothing in this branch spawns two agents at once. What is actually asserted: the agent's own prompt text (claims atomically, the loser exits silently and never overwrites,
wxEEXIST is not a lock wait, the heartbeattouch, and the delete-as-final-act —tests/tracker-agent.test.ts); the two-sided staleness bound, where the hook'sTRACKER_PROCESSING_STALE_SECS=600and the agent's**600 seconds**literal are compared intests/seams/tracker-claim-staleness.test.ts, which reports an unstated bound rather than reading it as agreement; and the hook's suppression of a fresh claim, so a live agent owns its batch. Together these cover the criterion's content; they do not cover the race itself, and calling them a concurrency test would overstate them.Prefix-shippability at this boundary. Clause (i) run and green (
build && test && pack --dry-run,git status --porcelainempty). Clause (iii) run: every Phase-3 artifact was swept for a reachable consumer in the end-state tree — all 30 provider defines map to a generated file and back, both directions, with no orphans. Clause (iv) verified bycmpagainstmain: identical. Clause (ii) — the tarball install into a scratch$HOMEand the five-command walk-through — is manual and is a checklist item below, together withnpm run test:integration, which spawns realclaudesessions and is the verification gate's to run.Byte budget, printed rather than quoted (
tests/tracker/byte-budget.test.ts):git.mdTwo further gates are asserted but deliberately not quoted here, because no row of that table prints them:
git.mdoutside the preamble against the unraised Phase-2 allowance, and the preamble's line count againstPREAMBLE_MAX_LINES. Their figures live in the assertions and in the failure messages, not in prose.No existing ceiling was raised. The one new entry is derived from its own measurement, its delta over the GitHub ceiling is held to what the provider actually adds, and both per-provider gates are now generated from one table so a fourth provider adds a row rather than a copied pair of assertions.
Read the Linear row, not the
git.mdrow, before adding always-loaded text. It containsgit.md, so a character added to the agent is charged to all four gates and the smallest margin decides — thegit.mdceiling's apparent slack is unspendable while the Linear row is the binding one.Figures in this section had drifted before the alignment pass measured them, so every "measured"/"headroom" number in
byte-budget.test.tsand in thenumeric-floors.jsonentries is re-derived from the printed table rather than carried forward. Per-pass decompositions are deliberately not recorded — here or in the test comments. They split each pass into characters added against characters recovered, and three successive re-derivations of those components disagreed with one another (PF-057), because no printed row produces them. The decisions they carried are kept instead, in words: the rendering rule was funded by deletingbacklink-shipped-issues' restatement of the D4 rate-limit rung — a GitHub header named in a provider-blind always-loaded line, where the always-loaded contract already states the rung provider-neutrally and defers the signal to the resolved provider's reference — and the second alignment pass then scoped that rule to issue refs, because the Output templates carry**PR**: #{number}slots as well and PR hosting stays on GitHub under every provider, so an unqualified "every rendered ref" told a jira spawn to render PR numbers unprefixed.Process-only acceptance criteria
jiraand notracker.md, a session start emits the directive once, the background Tracker agent writes the file, and the next tracker op resolves from it, with zero user-visible narration and zero questions/implement PROJ-123produces branch{type}/PROJ-123-{slug},- **Issue**: PROJ-123,- **Tracker**: jira, and the rendered PR link line$HOMEand the five-command walk-through driven on a GitHub projectRelated Issues
Closes #325
Part of #321 · Probe follow-up: #343