Mission Specification: Annoying Bugs Sweep

Mission Branch: fix/annoying-bugs-sweep Created: 2026-07-27 Status: Approved (revised post-squad) Input: Operator request — "Address these all in an annoying bugs mission": #2985, #1840, #2983, #2984. Folded in: #2987 (P0, Windows/reliability) added 2026-07-27 post-squad. Change mode: normal (no change_mode key; see Change Classification for why bulk_edit was set and then reverted)

> Revision note. A post-spec adversarial squad (architect / debugger / reviewer / planner lenses) > returned 3 CRITICAL findings and one disqualifying scope reversal against the first draft. All > confirmed findings are folded in here. The most important: the first draft's causal model of the > P0 was factually wrong, and its "zero raw reads" requirement silently reversed a binding > constraint on #1840. Both are corrected below. Squad evidence is cited inline so the corrections > are auditable rather than asserted.

Intent Summary

Five defects share one shape: a surface that looks authoritative but silently misdirects.

Two misdirect machines. A birth-cutover seed can supersede a work package's terminal lane, so a finished WP reads back as claimed (#2985, P0, live on main at d0a5bacf7). And the post-merge dead-code gate hardcodes a POSIX grep over src/*/.py — so it crashes on Windows, and on any non-Python or non-src/ consumer project it reports "0 unreferenced public symbols" without having examined anything (#2987, P0).

Three misdirect agents — profile-load surfaces instruct raw .agent.yaml reads that bypass doctrine resolution (#1840), a plain-language styleguide teaches a command that does not exist (#2983), and the invocation lifecycle opens under one command path but closes under another (#2984).

The unifying acceptance property: no shipped surface may confidently assert something false. #2987 is the thesis at its sharpest: a gate that returns a clean verdict it did not earn. For #2985 the false assertion is machine state; for the other three it is an instruction to an agent.

The P0 mechanism (corrected)

The first draft claimed the seed is "appended after the terminal transition" and that "the reducer replays in order, so the last transition wins". This is false. src/specify_cli/status/reducer.py:315 is sorted_events = sorted(unique_events, key=lambda e: (e.at, e.event_id))file position is discarded entirely. The real chain is a timestamp collision plus a lexical tiebreak:

1. backfill_runtime_state.py::_claim_anchors anchors a claim seed to the WP's first transition into claimed; when the WP never explicitly entered claimed, it falls back to the WP's earliest transition of any lane — which for a finished WP is its terminal transition. 2. The seeded planned -> claimed is therefore stamped with a timestamp byte-identical to the terminal event's at. 3. reducer.py::_should_apply_event has a same-timestamp precedence layer, but it arbitrates only rollback-vs-forward. done (forward) vs seeded claimed (forward) matches neither branch and falls through to "apply". 4. The tie therefore resolves on lexical event_id. A seed id is deterministic_ulid(mission_id|wp_id|field) — a hash-derived ULID whose first character is uniform over 0-7, whereas real ULIDs begin 01. The seed usually wins.

Two consequences the first draft missed. First, whether any given WP rewinds is a per-(mission_id, wp_id, field) hash outcome, so this fires on some terminal WPs rather than all — the profile of a bug that escapes notice. Second, any fix that merely changes where the row is written is a no-op that reads as a fix; the lever must be the anchor, the seed set, or the precedence rule.

Primary actor is a Spec Kitty operator or a delegated agent. The rule that must always hold: a work package that has legitimately reached a terminal lane never silently leaves it, and no shipped prompt surface instructs a mechanism that cannot work.

Priority vocabulary

Tracker priorities (P0P3) and user-story ranks are distinct scales. Stories below are ranked US-High / US-Med / US-Low to avoid the collision the first draft had (US1 labelled "P1" while its issue is the P0).

User Scenarios & Testing (mandatory)

User Story 1 - A finished work package stays finished (Rank: US-High · issue #2985, P0)

An operator runs spec-kitty accept on a mission whose work packages are approved or done. A birth-cutover seed carrying the terminal event's own timestamp wins the (at, event_id) tiebreak and supersedes the terminal lane, so the WP reads back as claimed. Accept stops converging, the mission cannot proceed to lane consolidation, and the corrupting write lands in the acceptance commit — persisted, not transient.

Why this rank: the only defect here that corrupts user data, on the terminal lifecycle seam, live on main today.

Independent Test: on a synthetic mission carrying genuinely unseeded legacy runtime state AND terminal transitions, run accept; assert no WP's canonical lane changed, all runtime slots are populated, and verify_backfill is ok.

Acceptance Scenarios:

1. Given a mission with ≥3 WPs — mixed terminal and non-terminal, at least one whose claim anchor collides with its terminal transition's atWhen spec-kitty accept runs on the real-commit path (mode commit), Then every WP's canonical lane is unchanged, and for each WP every seeded transition for that WP sorts strictly before that WP's terminal transition under (at, event_id). (Quantified per-WP: a non-terminal WP's legitimate anchor may legally be later than a different WP's terminal at; the reducer keys state by wp_id.) 2. Given that mission after a first successful accept, When accept runs again, Then the gate converges (summary.ok) with no outstanding work packages. Only commit exercises the stampaccept.py:601 gates it on commit_required, so --no-commit never reaches _stamp_birth_cutover_for_accept and diagnose is read-only. Both are convergence-only assertions and must be labelled as such, not counted as coverage of the #2985 seam. 3. Given any accept run, When it completes, Then no appended event changes any WP's canonical lane. (Quantified on lane-delta, not event count — the first draft's "unless the stamp had genuinely new runtime state" escape clause was satisfied by the bug itself.) 4. Given a mission that has genuinely never been cut over and carries no terminal transitions, When the stamp runs, Then it still seeds runtime state and CutoverResult.flipped is True — the fix must not disable the feature. 5. Given a WP carrying both a terminal transition and genuinely unseeded legacy runtime state, When accept runs, Then (a) its canonical lane is still terminal, (b) the three claim-borne slots — shell_pid, shell_pid_created_at, agent — are each present in the reduced snapshot and equal to the legacy value, and (c) verify_backfill reports ok (supporting only). Clause (b) must not be asserted via _has_snapshot_runtime: it is an any() over twelve slots, and assignee/tracker_refs/subtasks/review ride annotations a claim-suppression fix leaves untouched — so it stays True while the claim slots vanish silently. Clause (c) is near-vacuous alone, because _verify_expected_seed_events rebuilds expectations from the same builder a suppression fix would edit. (This is the anti-disable oracle: a fix of the shape "skip the seed whenever the WP is terminal" satisfies scenarios 1–4 while silently discarding shell_pid/agent/assignee/subtasks/review — the same data-loss class as the P0.) 6. Given a terminal WP, When an explicit forced transition re-opens it, Then that still succeeds — the fix must not freeze terminal WPs. 7. Given the same mission processed by the merge seam rather than accept, When the cutover runs, Then the second seam is a no-op and its own verify still passes.


User Story 2 - The post-merge review gate runs everywhere and never passes vacuously (Rank: US-High · issue #2987, P0)

spec-kitty review --mode post-merge shells out to POSIX grep with no shutil.which probe and no FileNotFoundError handling. On Windows without grep on PATH the gate does not degrade — it raises, after the merge has already succeeded, so the mission never receives a post-merge verdict.

The vacuous pass is a separate defect with a different lever, and the first framing of this story got it wrong in the same way the first draft got the P0 wrong. Symbol discovery is already scoped before grep is ever reached: _dead_code.py:71-74 runs git diff {baseline}..HEAD -- "src/", and the extraction regex matches only Python def|class. On a non-src/ or non-Python project new_symbols is empty, the reference-search loop never executes, and the gate prints "0 unreferenced public symbols" without grep running at all — a clean pass it did not earn.

The consequence is concrete: swapping the matcher for git grep closes the crash (FR-014) and is a complete no-op on the vacuous pass (FR-015). FR-015's levers are the diff pathspec and the language regex, plus a fail-loud undeterminable verdict — never the reference search.

Why this rank: a second P0. The crash is a hard failure on a supported platform at a terminal lifecycle seam; the vacuous pass is silent and ships to every non-Python consumer project. Verified against live source — src/specify_cli/cli/commands/review/_dead_code.py:93-98 has no shutil.which and no try/except (the module imports no shutil at all), and the git diff subprocess at :71-74 is equally unguarded. This repo is src/-rooted Python, so the vacuous pass never fires here — it harms only consumer projects, which is why it shipped.

Independent Test: run the gate with grep removed from PATH and assert a structured diagnostic rather than a traceback; run it against a non-Python / non-src/ fixture and assert it does not report a clean pass.

Acceptance Scenarios:

1. Given a host with no POSIX grep on PATH, When review --mode post-merge reaches the dead-code gate, Then it completes and emits a verdict — no unhandled FileNotFoundError. 2. Given a repository whose sources are not under src/, or are not Python, When the gate runs, Then it does not report a clean pass; it either scans the correct file set or emits an explicit undeterminable verdict. A zero-symbol result must be earned, not assumed. (tests/.../review/test_dead_code_baseline.py:86-110 currently codifies the opposite — it asserts the clean-pass string against a non-git tmp_path. Retarget that assertion to the undeterminable verdict; do not delete the case and do not weaken FR-015 to keep it green.) 3. Given the gate scans successfully on a POSIX host today, When the fix lands, Then the set of symbols it reports is unchanged — this is a portability and honesty fix, not a semantics change. 4. Given a contributor removes the portability guard, When the suite runs, Then a test asserting on an injected FileNotFoundError fails. It must not assert by patching shutil.which, which greens the moment someone re-adds a raw subprocess.run beside the guard. Mark it fast, not windows_ciwindows_ci is deselected from the default pole, so the guard would never run on the CI most PRs hit. (No such test exists today, and no windows_ci marker covers this path — which is why the defect shipped.)


User Story 3 - A delegated agent loads the profile it was actually assigned (Rank: US-Med · issue #1840, P2)

Shipped prompt surfaces instruct agents to read a profile's .agent.yaml directly. That bypasses doctrine resolution — specializes_from lineage, pack overlays, and enhances (field-merge) versus overrides (full replacement). For a shallow-lineage built-in the raw read coincides; for a pack-extended profile it silently yields a different profile with no error.

Why this rank: silent divergence with no failure signal, growing with the 3.3.x pack ecosystem. Held at the issue's current priority:P2 — the escalation to P1 flagged in #1840's triage is still the operator's open call and is not resolved by this spec. Scope is taken at full depth regardless, because the guard is the cheap part and is what prevents recurrence.

Independent Test: enumerate every tracked doctrine prompt surface; assert none instructs a raw .agent.yaml read as the primary mechanism, and that a guard fails when one is reintroduced.

Acceptance Scenarios:

1. Given any tracked doctrine prompt surface, When it instructs an agent how to load an assigned profile, Then the primary mechanism named is resolver-backed (spec-kitty agent profile show <id> or spec-kitty charter context --include agent-profile:<id>). 2. Given a harness that cannot shell out to the CLI, When a surface offers a raw-read fallback, Then that fallback is explicitly scoped to such harnesses and carries an inline resolution-divergence caveat. (C-006 — see below. An unscoped ban re-breaks open P1 #2304.) 3. Given the adversarial-squad skill and its governing procedure — the highest-traffic occurrence, and the one this mission's own review executed verbatim — When a delegate is dispatched, Then the instruction names the resolver command as primary, with the #2304-scoped fallback available to read-only harnesses. 4. Given a contributor reintroduces an unscoped raw-read instruction, When the suite runs, Then a guard fails and names the offending file. 5. Given the canonical spk-doctrine-profile-load skill, When an agent follows it, Then it is self-sufficient — the substantive mechanics live in the canonical skill and the legacy alias points to it, not the reverse. (Restated: the first draft asserted a mutual-deferral loop that does not exist. ad-hoc-profile-load does not defer back. The real defect is directional — the canonical name is a ~20-line stub whose detail lives in the deprecated alias.) 6. Given the #1840 ticket body, When an implementer reads it, Then it no longer states that reading the YAML directly is the reliable mechanism, nor implies #1636's commands are missing.


User Story 4a - The styleguide names a real command (Rank: US-Low · issue #2983, P2)

The plain-language styleguide's good_example instructs spec-kitty status, which does not exist. Agents copy good_example blocks — that is their function.

Acceptance Scenarios:

1. Given plain-language.styleguide.yaml's good_example, When an agent runs the command it names, Then the command exists (spec-kitty agent tasks status). 2. Given the published docs/ surfaces that carry the same phantom command, When they are swept, Then they name a real command or are explicitly recorded as out of scope.


User Story 4b - The invocation opener is discoverable from the closer (Rank: US-Low · issue #2984, P3)

An operator opens an Op with top-level spec-kitty dispatch but closes it with spec-kitty profile-invocation complete. Looking for the opener where the closer lives yields "No such command" — which is exactly how #2984 was filed as a CLI defect when the CLI was fine.

Acceptance Scenarios:

1. Given spec-kitty profile-invocation --help, When an operator looks for how to open an invocation, Then the help text names spec-kitty dispatch explicitly.

(Split from 3a: the two share no file, no test, and are independently shippable.)

Edge Cases

verify compares the full payload. Existing rows cannot be replaced by appending the same id: deduplication preserves the first row. The compatibility path therefore needs a distinct, deterministic repair identity that restores the state reduced with migration seeds excluded, preserves later legitimate writers, and converges without rewriting history (FR-010).

on COORD; tasks/ frontmatter reads from PRIMARY; status_phase writes to PRIMARY. The terminal-lane knowledge the fix needs exists only on the COORD leg, and the two seams resolve COORD through different resolvers.

a load instruction; the guard must discriminate on the surrounding imperative.

unscoped instruction: correctness by coincidence is not correctness.

  • A mission that has genuinely never been cut over — the stamp must still work (scenario 4).
  • A terminal WP re-opened by an explicit forced transition — must remain possible (scenario 6).
  • Already-seeded corpora. Seed ids namespace on mission_id|wp_id|field and exclude at, while
  • Coordination topology: three surfaces, not two — seed write + anchor read + verify + snapshot land
  • A .agent.yaml path cited as data (schema example, applies_to list, cross-reference) is not
  • A profile with no lineage, where raw and resolved reads coincide — the guard still rejects the

Requirements (mandatory)

Functional Requirements

IDUSTitleStatementPriorityStatus
FR-001US1No seeded value supersedes current WP stateAs an operator, I want no seeded historical event to supersede any WP's current lane or runtime slots, so finished work stays finished. Universally quantified over WPs, and over lane and annotation slots.HighOpen
FR-002US1Accept converges on an unchanged treeAs an operator, I want a second accept on an unchanged tree to converge, in each mutating mode, with the stamp reporting success (error is None).HighOpen
FR-003US1Cutover remains functionalAs a maintainer, I want the cutover to still seed genuinely-absent runtime state and still flip status_phase, so the fix does not disable the feature.HighOpen
FR-004US1All writing cutover callers behave consistentlyAs a maintainer, I want every writing caller of the shared cutover authority to hold FR-001, so the fix is not accept-only.HighOpen
FR-005US1Seed set and verify cannot disagree, without collapsing verifyAs a maintainer, I want the seed builder and verify_backfill unable to disagree about which WPs need seeding, while verify retains at least one witness derived from the legacy reader rather than from the builder — so suppressing a seed cannot make status_phase permanently un-flippable, and verify does not become tautological.HighOpen
FR-006US1Reproduction is collected by a named CI jobAs a maintainer, I want the #2985 reproduction asserted to be selected by a named CI job, so this P0 cannot silently return.HighOpen
FR-007US3Resolver-backed command is the primary mechanismAs a delegated agent, I want every profile-load instruction to name a resolver-backed command as primary, so I load the profile I was actually assigned.MediumOpen
FR-008US3Read-only-harness fallback preserved and scopedAs an agent in a harness that cannot shell out, I want a scoped raw-read fallback with an inline divergence caveat, so #2304 is not re-broken.MediumOpen
FR-009US3Canonical profile-load skill is self-sufficientAs an agent, I want the canonical skill self-sufficient — mechanics land in spk-doctrine-profile-load/references/.md (the CanonicalSkill.references mechanism, precedent spk-meta-skill-map/references/), not inlined: test_spk_skill_pack.py:108 caps spk- bodies at 80 lines and ad-hoc-profile-load is 268. Flipping the convention across all 13 alias pairs is out of scope.MediumOpen
FR-010US1Already-seeded corpora remain flippableAs an operator with an already-seeded corpus, I want a deterministic append-only compatibility repair for an old colliding seed, so the intended pre-seed state is restored, later legitimate writers still win, verification succeeds, and reruns append nothing.HighOpen
FR-011US3#1840 ticket body no longer misdirectsAs an implementer, I want both stale claims struck: the "reading the YAML directly is the reliable mechanism" advice, and the "zero occurrences of either canonical command anywhere in src/doctrine/" assertion, which is false — there are 7 occurrences across 4 skills. (verification_method: manual — permalink pasted in the PR body.)MediumOpen
FR-012US4aStyleguide and docs name real commandsAs an agent, I want doctrine and published docs to name commands that exist — scoped to docs/api/environment-variables.md, docs/api/upgrade-lifecycle.md, docs/architecture/launch-readiness-future.md, docs/guides/install-and-upgrade.md. Excludes docs/changelog/CHANGELOG.md (a shipped release note; rewriting it is history rewriting, and root CHANGELOG.md is a symlink to it, so editing its body would collide with both P0 stanzas on C-005's excepted file). Several occurrences mean "any command" generically and need per-site judgement, not substitution.LowOpen
FR-013US4bInvocation opener discoverable from closerAs an operator, I want profile-invocation --help to name spec-kitty dispatch.LowOpen
FR-014US2Dead-code gate is portableAs a Windows operator, I want the post-merge dead-code gate to complete without a POSIX grep on PATH, so a successful merge still yields a verdict.HighOpen
FR-015US2Dead-code gate never passes vacuouslyAs a consumer-project maintainer, I want a zero-symbol result to mean the gate examined the right files, not that it matched nothing — it must scan the correct set or fail loudly.HighOpen
FR-016US2Portability is regression-guardedAs a maintainer, I want a fast-marked test that injects FileNotFoundError into the subprocess (not one that patches shutil.which) plus a non-Python-layout fixture, so neither half of this class can silently return.HighOpen

Non-Functional Requirements

IDTitleRequirementCategoryPriorityStatus
NFR-001Red-first proof, pinnedA NEW test reproducing the lane supersession is committed, with its node id named in the PR body, demonstrated failing at git merge-base HEAD upstream/main and passing after. Citing a pre-existing failing test does not satisfy this.ReliabilityHighOpen
NFR-002Every new branch testedEvery new branch or helper carries a focused test named in the PR body. Self-contained: the repo's enforced diff-cover --include critical_paths does not cover migration/, cli/, or acceptance/, so the 90% gate cannot be appealed to at the fix site.MaintainabilityHighOpen
NFR-003Guard is enumerable and fail-closedThe raw-read guard scans exactly one root — src/doctrine/* — asserts a non-zero scanned-file count, and names each offending file. That single root is total: per ADR 2026-07-19-1, skill projection is a byte-for-byte shutil.copy2, so a source-tree guard provably covers every projected surface (DIRECTIVE_043, closed by construction). It must not reference .agents/* (untracked, outside the guard's denominator) nor "the generator's render path", which names no content source — skills/registry.py resolves the canonical root back to src/doctrine/skills.ReliabilityMediumOpen
NFR-004Attribution, not green-washingEvery test greened is first confirmed failing at the merge base, evidenced by a committed baseline artifact. Pre-existing reds are reported as such and left alone.ProcessHighOpen

Constraints

IDTitleConstraintCategoryPriorityStatus
C-001Single cutover authority, full caller setNo forked writer. The authority has five writing callers, not two: accept, merge, the spec-kitty upgrade migration (m_zz_runtime_state_backfill.py, which passes no status_feature_dir and so collapses both legs), migrate backfill-runtime-state (single + corpus). Any not covered must be scoped out with a reason.TechnicalHighOpen
C-002Seed ordering, not reducer precedencePlanning resolved the primary lever to per-WP seed-anchor clamping: when a WP already has transition or annotation history, every newly-created migration seed for that WP must sort strictly before the earliest existing row under the reducer's exact (at, event_id) ordering. An already-persisted bad seed is repaired with a distinct deterministic append-only compatibility identity; the repair restores the state obtained with migration seeds excluded and must not override a later legitimate writer. Seed suppression is barred because it discards claim-borne runtime slots; reducer precedence and event-stream rewrites are barred because they change global semantics or history. Verification must independently witness the three claim-borne legacy values.TechnicalHighResolved
C-003No new top-level status#2983 is resolved by correcting the surfaces, not by minting a top-level status. Note a spec-kitty.status slash command does ship in the agent surface, which plausibly explains the example — that split is not a reason to add a CLI verb.TechnicalMediumOpen
C-004Historical artifacts immutableArchived kitty-specs/ snapshots referencing retired commands are excluded.TechnicalMediumOpen
C-005P0 file-set separabilityEach P0 work package's changed-file set is disjoint from every other work package's, CHANGELOG.md excepted (each P0 stanza a self-contained block; its body is never edited — see FR-012). To be re-verified at tasks time against C-001's full caller set — the spec-time enumeration was incomplete, omitting m_zz_runtime_state_backfill.py, cli/commands/migrate/, acceptance/, review/__init__.py, and (if /plan picks the C-002 precedence lever) status/reducer.py. The invariant survived every check run so far — src/specify_cli/cli/commands/review/_dead_code.py never meets the #2985 set — but the constraint must not assert a verification it did not perform.ProcessHighOpen
C-006Read-only-harness fallback preservedA raw .agent.yaml read may remain only where scoped to harnesses that cannot invoke the CLI, with an inline resolution-divergence caveat. An unscoped ban re-breaks open P1 #2304.TechnicalHighOpen
C-007No profile-invocation dispatch alias#2984 is fixed by help text only, landing in the Typer epilog — the completion manifest schema is {help, hidden, deprecated, commands} with no epilog, so an epilog edit leaves it untouched. Putting the pointer in help= would regenerate _completion_manifest.json and break C-005 disjointness exactly as the alias option would.TechnicalMediumOpen
C-008Dead-code gate semantics unchanged on POSIXPortability + honesty only: on a POSIX src/-rooted Python host the reported symbol set must be identical before and after. git grep does not preserve this by construction — it misses untracked and gitignored .py, needs --recurse-submodules, and exits 128 outside a work tree (which an existing test exercises). Byte-identity holds on this repo only by contingent fact. A pure-Python scan is the recommended primary. Two quirks must be preserved verbatim: the "test" not in f substring filter (not a path-component match), and cwd-relative output compared against repo-root-relative POSIX defined_in paths.TechnicalHighOpen
C-009Coordinate with the #2987 reporterThe reporter offered a PR for their option 2 (git grep). Confirm before implementing. Note their option closes FR-014 only and is a no-op on FR-015, and does not satisfy C-008 by construction — so the coordination is about splitting the work, not handing over the story.ProcessHighOpen

Key Entities

order on (at, event_id)** — file position is not semantically significant. Same-at events are arbitrated by _should_apply_event, which today handles only rollback-vs-forward.

field) — hash-derived, excludes at, so re-runs are idempotent but re-anchoring breaks verify. Its at comes from the anchor chain: _claim_anchors (first claimed, else earliest transition of any lane) → _synthesize_claim_anchor (shell_pid_created_at, else meta.json.created_at`).

lineage, overlays and enhances/overrides — as opposed to a raw file read.

  • Status event: an append-only record in status.events.jsonl. The reducer imposes a **total
  • Birth-cutover seed: a synthesized historical event. Id is `deterministic_ulid(mission_id|wp_id|
  • Profile-load surface: any shipped prompt artifact instructing an agent how to obtain a profile.
  • Resolver-backed profile read: obtained through AgentProfileRepository resolution, honouring

Success Criteria (mandatory)

AND carry terminal transitions, zero terminal WPs change lane after accept — with an explicit non-vacuity assertion that the fixture reds on the unfixed code. (The committed dogfood corpus is a dead oracle: a dry-run over all 324 missions yields 323 "nothing new to seed" and 1 "no tasks/ directory" — zero can seed, so zero can rewind, before any fix.)

profile-load mechanism; any remaining fallback is #2304-scoped and caveated.

failing at the merge base and passing after.

on a POSIX src/-rooted Python repo its reported symbol set is byte-identical to today's (C-008).

  • SC-001: On a synthetic drifted fixture containing missions that genuinely still need seeding
  • SC-002: A second accept on an unchanged tree converges in each mutating mode.
  • SC-003: Zero tracked doctrine prompt surfaces name a raw .agent.yaml read as the primary
  • SC-004: The styleguide good_example and the published docs/ occurrences name real commands.
  • SC-005: "dispatch" appears in profile-invocation's help output.
  • SC-006: The #2985 reproduction's node id is named in the PR body, with committed evidence of
  • SC-007: review --mode post-merge completes and emits a verdict with no POSIX grep on PATH.
  • SC-008: On a non-Python / non-src/ fixture the dead-code gate does not report a clean pass;

Assumptions

file placement, seed suppression, or global reducer precedence. C-002 records the planning decision.

were verified to exist (profiles_cmd.py:319, charter/context.py:28-30).

  • The chosen lever is per-WP seed-anchor clamping across transition and annotation history — not
  • spec-kitty agent tasks status is the replacement for #2983. Both resolver commands named in US2
  • #2984's CLI is not defective; verified working during triage.

Out of Scope

does not worsen it.

expected to survive #2399, because prompt surfaces still ship to harnesses outside the structural path.

#2983, deferred to keep this mission bounded), #2527 / #2748 / #2690 (generated-skill propagation, which owns the half of #1840 that NFR-003 deliberately does not enforce).

dead-code gate specifically; the general layout assumption stays with #2330.

  • #2304 (read-only harness cannot invoke the CLI) — not solved here; C-006 ensures this mission
  • #2399 structural profile loading. Complementary; #1840 is its prompt-level stopgap. The guard is
  • #2957 / CI shard vacuity at large. FR-006 closes the hole for this P0 only.
  • #2400 (#1840's parent epic), #2961 (skills prescribing a retired path — same defect class as
  • #2330 (the broader Python/pytest layout assumption). #2987's vacuous-pass fix addresses the
  • #630 is closed and cited only as precedent (same Windows subprocess class).
  • Archived kitty-specs/ snapshots (C-004).

Delivery sequencing note

Four P0s are open concurrently: #2985 and #2987 (both this mission), #2962, #2939. #2985 is sequenced first because it is the only one that persists corruption into an acceptance commit, and it is a fresh regression from a just-merged PR, so the fix window is cheapest now.

Change Classification

Classified normal (no change_mode key in meta.json).

Although several profile-load surfaces need correction, their occurrences are not one uniform replacement: resolver-capable prompts must lead with the canonical command, read-only harness fallbacks must remain with a divergence caveat, procedure prose needs different wording, and benign .agent.yaml data references remain unchanged. The work is therefore a bounded set of individually designed defect fixes, not an occurrence migration. NFR-003's source-tree guard closes future drift by construction; no occurrence_map.yaml is required.