tracker-project-milestone-attached — call runTrackerProjectMilestoneAttachedProbe(projectRoot, deps) from adapters/_shared/src/tracker_project_milestone_attached.ts. For each status: active FR with a tracker block, fetch the issue via deps.getIssue(<ticket-id>) and verify the milestone landed on the ticket. The verification surface is adapter-aware, selected by deps.milestoneBinding (a field on the deps object the caller supplies — no adapter key is parsed for it; defaults to object when absent): the object binding (Linear; mcp__linear__get_issue) asserts the issue's projectMilestone.name byte-equals the canonical local heading (parsed from specs/plan/M<N>.md's H1, anchor stripped); the epic binding (Jira's primary path; mcp__atlassian__getJiraIssue) routes through milestoneBindingPresent(issue, heading, "epic") — the parent-Epic check, asserting the ticket's parent sanitizes back to the Epic-keyed milestone token M_<epic-key>, degrading to the label surface for grandfathered numeric M<N> milestones; the label binding (Jira's legacy fallback; same MCP) asserts the issue's labels array contains milestone-<M-token> (the leading milestone token of that same canonical heading — numeric M<N> or Epic-keyed M_<epic-key> per the union grammar) — labels are Jira's create-on-write milestone surface, since the atlassian MCP exposes no milestone object. Vacuous when mode: none, the FR is archived, the FR has no tracker block, or the plan file is missing (probe #27 owns that diagnostic). Missing or mismatched binding (object: name mismatch; epic: no parent Epic sanitizing to the expected token, label fallback also absent; label: the expected milestone-<M-token> absent) → GATE FAILED rendering the offending names/labels byte-by-byte (em-dash drift visible) plus a binding-aware NFR-10 remedy that names only operations that exist. The MISMATCH kind reaches only the object binding (its sole call site sits inside attached !== heading, and attached comes only from projectMilestone.name), so its remedy is scoped there: rename the tracker milestone to the canonical heading via mcp__linear__save_issue, or — when the tracker side is the correct one — edit the specs/plan/M<N>.md heading to match. An earlier wording also offered mcp__atlassian__editJiraIssue "on the label binding", which was false twice: that arm is unreachable, and there is no tracker milestone to rename on the label surface, since labels ARE the milestone surface there. Under the epic binding a name mismatch is not a binding failure at all — the Epic is resolved by its KEY, never its summary — so the missing-binding remedy names the absent Epic key instead. No rename flag has ever existed on /spec-write; the remedy this replaced ordered one that never shipped. The Epic-keyed epic binding is the exception: the Epic is resolved by KEY and its summary is never read, so a name mismatch is not a binding failure there — its remedy names the absent Epic key (the one a candidate Epic's key would sanitize to, e.g. GF-78 → M_GF_78) and the mcp__atlassian__editJiraIssue additional_fields.parent write that binds it, never a rename of anything.
Capability-gap downgrade (STE-194 + STE-214). When the FR's ## Notes section contains a word-bounded match of any of the three milestone-attach capability keys emitted by /spec-write per skills/spec-write/SKILL.md § Step 7 — milestone_attach_skipped_adapter_limit (canonical), milestone_attach_unavailable (deprecated alias per STE-198, honored for one minor-version cycle), or milestone_create_required — AND the ticket has no projectMilestone binding, the missing-binding outcome routes to ADVISORY (severity = note, NOT GATE FAILED) with the canonical prose milestone-attach skipped — capability gap declared in FR Notes (<key-found>). Detection rule: case-sensitive, word-bounded match (\b<key>\b) scoped to the FR's ## Notes body — substring matches (xmilestone_attach_skipped_adapter_limitX) and matches in other sections (## Acceptance Criteria, ## Technical Design) do NOT count. Mismatched bindings still hard-fail; the token only excuses absence, not divergence. Token-present + binding-present is PASS (binding wins; the token is informational). The advisory closes the smoke-driver false-positive surface where Linear-mode runs created tickets in a project with zero milestones by construction. Decision table:
## Notes contains any milestone-attach capability key | projectMilestone set on tracker | Probe #26 outcome |
|---|
| no | yes | PASS |
| no | no | GATE FAILED |
| yes | yes | PASS (binding wins; token informational) |
| yes | no | ADVISORY (capability-gap downgrade — prose names the specific key found) |
Recognized key set: milestone_attach_skipped_adapter_limit (canonical), milestone_attach_unavailable (deprecated alias), milestone_create_required.
Skip-reason rendering (STE-238 AC-STE-238.8). When this probe skips for a vacuous cause (mode: none, FR archived, FR has no tracker: block, plan file missing), the rendered skip-reason MUST route through renderProbeSkipReason(...) from adapters/_shared/src/tracker_probe_skip_reason.ts and MUST NOT contain the literal substring require Linear MCP (the F9 LLM-paraphrase regression caught by /conformance-loop iteration 1, 2026-05-07). The helper produces an accurate cause-named line — mode: none → skipped — \mode: none`…; archived/no-tracker FR → skipped — active FR has no `tracker:` block (); etc. — so the operator sees the structural cause, not a vendor-MCP paraphrase. Vendor-neutral by construction: the helper handles Linear and Jira identically. Unit-tested at adapters/_shared/src/tracker_probe_skip_reason.test.ts`.
Test coverage: tests/gate-check-tracker-project-milestone-attached.test.ts.