Implementation Plan: Charter-epic golden path fits its NFR-007 budget and is un-skipped

Branch: issue-4213-golden-path-nfr-budget | Date: 2026-09-23 | Spec: kitty-specs/charter-epic-golden-path-nfr-budget-01M35H35/spec.md Input: Mission specification from kitty-specs/charter-epic-golden-path-nfr-budget-01M35H35/spec.md, operator rulings 1-5 in reviews/spec.ruling.md, prior reasoning in tracer-approach.md / tracer-design-decisions.md.

Note on template drift: the spec-kitty plan --mission ... --json scaffold populated plan.md from a stale template (header referenced the retired src/doctrine/missions/software-dev/command-templates/plan.md path and used a ## Constitution Check section name) instead of the canonical packs/built-in/missions/software-dev/templates/plan-template.md (## Charter Check). This plan is written against the canonical template's structure per CLAUDE.md's "use canonical sources, never improvise" rule. See tracer-tooling-friction.md for the friction entry.

Summary

test_charter_epic_golden_path (tests/e2e/test_charter_epic_golden_path.py) is un-skipped and made to reliably clear its unmodified 120s @pytest.mark.timeout budget (NFR-007), with a real GitHub Actions tests (e2e) measurement at or under the Ruling-2 hard ≤110s bar. This is a CLI-only change: a test-fixture redesign (lever A, FR-002/C-006) plus a CLI-startup cost trim (lever C, FR-003/FR-005). Investigation during this planning pass (see Research §CLI-startup cost decomposition) found that the dominant, previously-undiagnosed cost driver for both levers is the same code path: ensure_global_agent_commands() (src/specify_cli/runtime/agent_commands.py), called from main_callback() (src/specify_cli/__init__.py:145-152) on every non-fast-pathed CLI invocation, unconditionally re-renders all 13 slash-command agents' 8 commands each (104 ruamel.yaml-backed template renders) with no freshness short-circuit before rendering — regardless of which agent(s) the invoking project actually uses. This runs on 9 of the golden path's 12 subprocess CLI calls (every call except the two next calls, which are already fast-pathed) including the fixture's own spec-kitty init call. The plan's primary lever-C mechanism adds a cheap freshness pre-check that skips the render when nothing has changed; the literal #4417-deferred "lazy command-module import" trim in register_commands() is a secondary, complementary, lower-risk change bundled into the same lever-C work. Lever A's fixture redesign benefits automatically from the same fix (the fixture's spec-kitty init call goes through the identical code path) plus one small, fixture-local secondary change: writing git config user.email/user.name directly into the fresh repo's .git/config instead of two additional git config subprocess calls.

Technical Context

Language/Version: Python 3.11+ (per charter Technical Standards) Primary Dependencies: typer, rich, ruamel.yaml (existing — no new dependency added by this plan) Storage: N/A (no persistence-layer change; touches the CLI's global runtime asset cache under ~/.kittify, an existing mechanism) Testing: pytest, targeted per CLAUDE.md's blast-radius rule (see Research §Baseline method) Target Platform: Linux/macOS/Windows CI runners (GitHub Actions ubuntu-latest for the gates this mission's evidence depends on) Project Type: Single project (spec-kitty CLI + its own test suite) — not web/mobile Performance Goals: test_charter_epic_golden_path ≤110s hard (NFR-002/Ruling 2) on a real GitHub Actions tests (e2e) run; charter §Performance <2s CLI-operation standard as supporting rationale for lever C (NFR-003) Constraints: 120s @pytest.mark.timeout unchanged (NFR-001/C-001); test stays on the per-push tests (e2e) shard (C-002); all 12 golden-path subprocess CLI calls (plus the fixture's own spec-kitty init call) stay real subprocess invocations of the source-tree CLI (C-003); no CI/router changes (Ruling 4); no skip-regression CI gate (Ruling 1 — C-005 stays prose) Scale/Scope: One test file, one fixture, ~2-3 runtime modules touched for lever C; one new regression-guard test module (FR-005)

Charter Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

  • Architectural alignment (DIRECTIVE_001): this change lands entirely inside the CLI's own runtime/test-infra layers (src/specify_cli/runtime/, src/specify_cli/cli/commands/, tests/e2e/, tests/performance/). It does not cross the kernel <- charter <- {glossary, runtime, mission_runtime} <- specify_cli enforced layer direction (see tests/architectural/test_layer_rules.py) and does not touch spec-kitty-events/spec-kitty-tracker external contract packages. PASS.
  • ATDD-first (C-011): FR-001 (un-skip) is itself the red-first ATDD anchor — the test already exists and is currently skipped; removing the skip makes it the acceptance mechanism (SC-001). FR-002/FR-003's changes are verified by the same pre-existing test going from "would time out if reverted" to "passes in budget." FR-005's regression guard is new: its structural half must be written RED first (see Research §Red-first per changed behaviour). PASS, with FR-005's own red-first sequencing tracked in tasks.
  • Canonical sources (DIRECTIVE_044): lever C's regression-guard structural half follows the existing #4409/#4417 precedent (tests/performance/test_cli_startup_budget_4409.py) rather than inventing a new pattern. PASS.
  • No cap loosening / no cadence move / no in-process substitution / no CI-router change: none of C-001, C-002, C-003, or Ruling 4 is touched by any lever in this plan — see Requirements §Constraints below. PASS.
  • Performance standard (§Performance, NFR-003): lever C's fix directly serves the charter's <2s CLI-operation standard for every spec-kitty invocation on the machine, not only this test — see Research §CLI-startup cost decomposition. PASS.

Re-checked after Phase 1 design (this same pass, since Phase 0/1 were done together for a mission this size): no new violations introduced by the concrete mechanisms chosen below. Still PASS.

Project Structure

Documentation (this mission)

kitty-specs/charter-epic-golden-path-nfr-budget-01M35H35/
├── plan.md              # This file
├── research.md          # Phase 0 output — CI-gate/baseline/lever research
├── data-model.md         # Phase 1 output — fixture before/after shape, asset-cache stamp shape
├── quickstart.md         # NOT produced — see rationale below
├── contracts/            # NOT produced — see rationale below
└── tasks.md              # Phase 2 output (/spec-kitty.tasks — not this command)

quickstart.md and contracts/ are deliberately skipped. This mission ships no new public contract, API, CLI flag, or user-facing workflow — it is an internal test-fixture redesign plus an internal CLI-startup performance fix. There is no "how an operator gets started with this mission's change" story distinct from "run spec-kitty as before" (quickstart would be vacuous), and no schema/API/event contract changes (see Contracts section below) to document in contracts/. Per the charter's Documentation Doctrine, a document that would serve no reader is not produced for its own sake.

Source Code (repository root)

src/specify_cli/
├── __init__.py                        # main_callback() — call-site change: gate
│                                       #   ensure_global_agent_commands() behind
│                                       #   the new freshness pre-check (no argv-
│                                       #   sniffing fast-path added here; the fix
│                                       #   lives inside the callee)
├── runtime/
│   └── agent_commands.py              # assess_global_agent_commands() — new
│                                       #   freshness-stamp pre-check before
│                                       #   _render_agent_commands() is invoked
└── cli/commands/__init__.py           # register_commands() — secondary, lower-
                                        #   risk lazy-import trim (the literal
                                        #   #4417-deferred item)

tests/
├── e2e/
│   ├── conftest.py                    # fresh_e2e_project fixture (FR-002) —
│   │                                  #   git config written directly to
│   │                                  #   .git/config instead of two `git
│   │                                  #   config` subprocess calls; benefits
│   │                                  #   automatically from the lever-C fix
│   │                                  #   for its own `spec-kitty init` call
│   └── test_charter_epic_golden_path.py  # skip decorator removed (FR-001/C-004)
└── performance/
    └── test_cli_startup_agent_commands_freshness.py   # NEW — FR-005 two-tier
                                        #   regression guard (structural, always-
                                        #   on + wall-clock, nightly `performance`
                                        #   lane only), modeled on
                                        #   test_cli_startup_budget_4409.py

Structure Decision: single project (Option 1 from the canonical template — this repository has no frontend/backend/mobile split). No new top-level directory. The one new file (tests/performance/test_cli_startup_agent_commands_freshness.py) lands beside the existing #4409 precedent it follows.

Seam, generated artifacts, contracts, upgrade chain (spec-kitty overlay — replaces tk's generic bullets)

1. Seam: CLI-only (src/specify_cli/) plus test infrastructure (tests/e2e/, tests/performance/). No kernel, doctrine, or sync-transport code is touched. New/changed code:

  • src/specify_cli/runtime/agent_commands.py — assess_global_agent_commands() gains a freshness pre-check (new helper function(s) in the same module).
  • src/specify_cli/__init__.py — no signature change to main_callback()'s call site is required if the freshness check is internal to assess_global_agent_commands(); if the plan's implementer finds a call-site change is cleaner, it stays confined to lines 145-152.
  • src/specify_cli/cli/commands/__init__.py — register_commands() (lines 181-257): convert the ~40 unconditional from . import <module> + registration calls into a lazy-lookup pattern keyed by the invoked subcommand name, generalizing the existing _is_next_fast_path/_is_live_work_hook_fast_path argv-sniffing idiom already used in this same file for exactly two commands.
  • tests/e2e/conftest.py — fresh_e2e_project (lines 390-452): git-config-via-direct-file-write change.
  • tests/e2e/test_charter_epic_golden_path.py — skip decorator removed (lines 797-805 only; line 806 @pytest.mark.timeout(120) and line 807 def test_charter_epic_golden_path untouched).
  • tests/performance/test_cli_startup_agent_commands_freshness.py — new file (FR-005 guard).

2. Generated artifacts: none touched. This mission does not run spec-kitty regen or spec-kitty doctrine regenerate-graph output through any behavior change — no packs/built-in/ template, doctrine pack, or generated agent-command file content changes. The regen-check job (.github/workflows/ci-router.yml, always-on) is expected to pass unmodified since no template source under packs/built-in/missions/mission-steps/ changes.

3. Contracts: none move. Doctrine schemas, mission step contracts, the orchestrator-api surface, and spec-kitty-events payload/envelope shapes are all untouched — this mission is test infra plus internal CLI-startup ordering (which commands get imported when, and when a global asset cache gets re-rendered), not a public contract. The next --json / agent mission / charter command envelopes the golden path exercises are read by this mission's test, never changed by it.

4. Upgrade/migration chain: untouched. No migration is added, no schema version bump, no .kittify/ on-disk shape change for end-user projects. The global ~/.kittify asset-cache freshness-stamp file (lever C) is a new internal cache artifact, not a project-facing schema — see data-model.md for its shape and why it needs no migration (it is created fresh, tolerates absence, and is never read by anything outside assess_global_agent_commands()).

Lever A — fixture redesign (FR-002/C-006)

Chosen mechanism (two parts, both concrete):

(a) Primary — inherited from lever C. The fixture's own spec-kitty init subprocess call (tests/e2e/conftest.py:425-434, via run_cli_subprocess) goes through the exact same main_callback() → ensure_global_agent_commands() path every other golden-path CLI call does (see Lever C below). Fixing that path's freshness short-circuit reduces the fixture's own init cost automatically, with no fixture-specific code needed for this part. This is the dominant savings source for FR-002: profiling during this planning pass (see research.md §CLI-startup cost decomposition) attributes roughly 55-65% of a bare spec-kitty init --ai codex --non-interactive invocation's own wall-clock cost to this one code path, on a warm global asset cache, with zero short-circuit today even when nothing changed.

(b) Secondary, fixture-local — git-config batching. fresh_e2e_project (tests/e2e/conftest.py:390-452) currently makes 3 separate git subprocess calls before spec-kitty init runs: git init -b main, git config user.email ..., git config user.name .... C-003 binds only the CLI's own subprocess invocations (spec-kitty calls), not raw git calls, so the two git config calls can be replaced by writing the [user] section directly into the freshly-created <project>/.git/config file (a small, stable, well-documented INI format) after git init runs — cutting 2 of the fixture's 5 subprocess spawns to 0. This is a small, low-risk win (each git subprocess spawn costs roughly tens of milliseconds; not the dominant cost, but genuinely additive and zero-risk).

Estimated time saved (estimate, not a promise, per the dispatch's own framing): (a) is expected to be the larger contributor — plausibly several seconds to low tens of seconds off the fixture's ~29s measured setup phase, scaled by how much of that phase's cost the freshness short-circuit actually eliminates on a real Actions runner (untested until the real measurement in Research §Actions evidence path). (b) is expected to save well under 1 second. Neither number is asserted as a committed budget; SC-003's real Actions measurement is the actual acceptance bar (see NFR-002).

C-006 precondition preservation. Neither part of this mechanism changes the ORDER of operations in the fixture: git init (now still one subprocess call) still runs before any commit, and the direct .git/config write (part (b)) happens between git init and spec-kitty init, writing only to the git config file — it creates no commit and adds no .kittify content. spec-kitty init (part of neither (a) nor (b), unchanged as a call) still runs against a zero-commit repository with no .kittify content, exactly as today. How this is asserted, not just assumed: the fixture already contains an implicit precondition (no assertion today — the sequence itself enforces it structurally). This plan requires adding an explicit assertion in fresh_e2e_project, immediately before the spec-kitty init call, of both halves of C-006:

  • assert not (project / ".kittify").exists() (no .kittify content copied in)
  • assert subprocess.run(["git", "rev-list", "--count", "HEAD"], cwd=project, capture_output=True, text=True).returncode != 0 or equivalently assert not (project / ".git" / "refs" / "heads" / "main").exists() (no commits yet — HEAD has no commits to count, or the main ref does not yet exist) — the exact assertion form is an implementation-time choice, but it must be a real, executable assertion inside the fixture, not a comment, so a future edit to the fixture's own ordering trips a real test failure rather than a silent regression of C-006's precondition. This also serves Acceptance Scenario 4 directly.

Single-consumer isolation preserved: neither part (a) nor part (b) introduces any new shared state between fresh_e2e_project and any other tests/e2e/ fixture or test. Part (a)'s freshness-cache fix lives in the CLI's own ~/.kittify global asset cache — a mechanism that already exists and is already shared machine-wide across every spec-kitty invocation (including other tests/e2e/ tests that also invoke spec-kitty via run_cli); making that cache's freshness check cheaper does not change what is shared, only how expensively the existing sharing is re-verified. Part (b) touches only the one fixture's own freshly-created project directory. No new fixture-sharing mechanism (e.g., a session-scoped template or clone source) is introduced, so there is nothing new to leak between tests — this directly satisfies the Edge Cases bullet's isolation concern without needing a broader redesign.

Lever C — CLI startup cost (FR-003/FR-005)

Authority for this section's primary/secondary ordering (operator Ruling 6, reviews/plan.ruling.md): the freshness pre-check below is presented as lever C's PRIMARY mechanism and the Typer command-surface trim as SECONDARY on the direct authority of the operator's Ruling 6, not on this plan's own inference from FR-003's outcome clause. tracer-design-decisions.md's "On whether this needed an operator ruling" subsection (in the "2026-09-23 — Plan phase: Lever A/Lever C mechanism choice" entry) records the plan-authoring agent's own self-authorized reasoning for treating the substitution as within its latitude; that reasoning is superseded as the authority for this ordering by Ruling 6 (see the tracer file's later "Ruling 6 applied" entry) — the operator has since ruled directly that lever C covers both mechanisms, freshness check first. The profiling evidence in this section (measured cost decomposition, the ~55-65%/~0.2s-per-call comparison) remains valid supporting evidence for WHY the freshness check works and is kept below; it is Ruling 6, not that evidence or the plan-authoring agent's own prior inference, that authorizes treating it as primary.

Seam, precisely: src/specify_cli/runtime/agent_commands.py::assess_global_agent_commands() (called via ensure_global_agent_commands(), itself called from main_callback() at src/specify_cli/__init__.py:145-152, guarded only by the existing argv-sniffing fast-path checks — _is_next_invocation/_is_live_work_hook_invocation/_is_session_start_invocation gating the surrounding block (__init__.py:137-139) plus a fourth, independent guard, _is_doctor_skills_invocation, gating the ensure_global_agent_commands() call specifically (__init__.py:151) — i.e. it runs on every OTHER invocation, including 9 of the golden path's 12 subprocess calls: spec-kitty init (fixture setup), charter generate, charter bundle validate, charter synthesize, charter status, agent mission create, agent mission setup-plan, agent mission finalize-tasks, retrospect summary; agent mission branch-context — check at implementation time whether it is also fast-pathed, current reading of _is_*_invocation suggests it is not; the two next --agent claude --mission ... --json calls (query + advance) already skip this path via _is_next_invocation).

What's eager that could be deferred: assess_global_agent_commands() (src/specify_cli/runtime/agent_commands.py:343-420) calls _render_agent_commands(key, templates, script_type) for every key in AGENT_COMMAND_CONFIG (src/specify_cli/core/config.py:55 — 13 slash-command agents) when agent_keys=None (the default ensure_global_agent_commands() passes), rendering each agent's PROMPT_DRIVEN_COMMANDS (src/specify_cli/shims/registry.py:57 — 8 commands) — 104 ruamel.yaml-backed template renders (render_template_text → ruamel.yaml.main.load) — before any comparison against existing destination bytes. There is no freshness pre-check: the function always renders first, then diffs. Profiling this planning pass (cProfile, isolated HOME, warm global asset cache, 3rd consecutive spec-kitty init --ai codex --non-interactive invocation against a fresh per-call project) measured this one call chain (ensure_global_agent_commands → _render_agent_commands → render_command_template → render_template_text → ruamel.yaml load) consuming ~60% of main_callback's own cumulative time and ~55-65% of the whole init invocation's uninstrumented wall-clock time (9.09s uninstrumented total on this machine for a warm-cache call; cProfile-instrumented run measured 24.9s total with 14.94s cumulative inside ensure_global_agent_commands, consistent after correcting for cProfile's own ~2.7x instrumentation overhead). This dwarfs the ~0.2s/call Typer-construction figure FR-003 cites from PR #4417's own "Deferred" note — that figure was measured via --help, which short-circuits before main_callback's body runs (click's eager --help option exits before the group callback's non-eager logic executes), so it never paid this cost and is not a representative proxy for a real subcommand invocation's startup cost.

Approach: add a cheap freshness pre-check to assess_global_agent_commands() that runs before any _render_agent_commands() call — a stamp comparing (a) the CLI's own version (__version__ / _get_cli_version(), already used nearby for the canonical-predecessor check), (b) a content signature of the command-templates source directory (_get_command_templates_dir()), and (c) the resolved agent-key set — against a small stamp file stored under home / "cache" (the same cache directory AssetPreparation already uses, src/specify_cli/runtime/asset_preparation.py:165-178). When the stamp matches AND an independent destination-health check (_all_global_agent_commands_healthy(), src/specify_cli/runtime/agent_commands.py:296-305 — an existing, currently-unused function with zero callers today) confirms every configured agent's rendered destination is complete, the function returns the prior OwnerAssessment (or an equivalent "already healthy" result) without calling _render_agent_commands() for any already-fresh key and without constructing an AssetPreparation batch at all; when either check fails (stamp absent/mismatched/unreadable, first run, a new spec-kitty version, an edited template, or a destination-health miss — e.g. a deleted or hand-edited command file), it renders and re-stamps as today. This preserves the existing design intent — every configured agent's commands stay globally warm regardless of which project/agent the user is currently in — while eliminating the redundant, unconditional re-render that happens today on every single invocation even when absolutely nothing changed. See data-model.md for the stamp's exact shape, the full four-condition read/write contract (including the malformed/legacy-stamp disposition), and the SK-243 ledger-interaction analysis (SPEC-KITTY-LEDGER.md § SK-243).

Secondary, complementary trim (the literal FR-003/#4417-deferred item): register_commands() (src/specify_cli/cli/commands/__init__.py:181-257) unconditionally imports ~40 command modules (from . import accept as accept_module, etc.) on every invocation not already covered by the next/live-work hook fast paths. This plan generalizes the same argv-sniffing idiom those two fast paths already use (_is_next_fast_path, _is_live_work_hook_fast_path, defined nearby in the same file) into a lazy command-lookup table so that a single-leaf-command invocation (which is what 11 of the golden path's 12 calls are — charter generate, agent mission create, etc.) only imports the one module tree its invoked command actually needs, not all ~40. This is the change PR #4417's own "Deferred" section named directly ("Making the command modules lazy would shave more... it deserves its own measurement rather than riding along here" — this mission is that measurement). Estimated ~0.2s/call × ~10 non-fast-pathed golden-path calls ≈ 2s total — small relative to the freshness-check fix above, but zero-risk and already explicitly scoped by the spec's own FR-003 text, so it is included alongside the larger fix rather than substituted for it.

Startup regression guard (FR-005/SC-002/Ruling 4), two-tier, modeled on #4409/#4417:

  • Structural half (always-on, fast per-PR tier, deterministic, no wall-clock): an AST-based scan, mirroring test_jsonschema_stays_out_of_module_scope in tests/performance/test_cli_startup_budget_4409.py, asserting assess_global_agent_commands() (or whichever function ends up owning the render call) contains a freshness-check code path before its first call to _render_agent_commands — concretely: parse the function body and assert the _render_agent_commands call site is reachable only after an early-return branch exists earlier in the function (a structural, non-vacuous shape check, not a hash of the function's source, so it survives cosmetic refactors while still failing if the early-return branch is deleted). A second, simpler structural check (mirroring #4409's own second guard) directly re-asserts register_commands()'s lazy-import shape: no top-level from . import <command_module> statement inside the eager (non-fast-path) branch outside the lazy-lookup table's own module-name-to-import mapping.
  • Wall-clock half (nightly-only, performance-marked, .github/workflows/ci-nightly.yml's performance-and-e2e job — never the per-PR fast-unit tier): a real subprocess timing test, modeled directly on test_help_stays_inside_its_startup_budget (tests/performance/test_cli_startup_budget_4409.py:133-149), but timing a REAL leaf-command invocation (e.g. spec-kitty context show --json or another cheap, side-effect-light leaf command) rather than --help — since (per the finding above) --help never pays the main_callback cost this guard exists to catch, timing --help would make the wall-clock half vacuous against exactly the regression this mission fixes. Budget generous enough to absorb CI contention (mirroring _HELP_BUDGET_SECONDS's stated design intent), tight enough to catch a full-render regression.
  • Explicitly ruled out: no hard wall-clock assertion inside tests/unit/ or any fast per-PR shard — both halves above are either deterministic-structural (fast tier) or wall-clock-but-nightly-only (performance marker), matching Ruling 4/FR-005/SC-002 exactly.

Red-first staleness tests for the freshness check (binding condition, operator Ruling 6, reviews/plan.ruling.md): distinct from the FR-005 regression guard above (which catches a future reintroduction of the pre-fix eager-render pattern), Ruling 6 binds a second, narrower requirement directly on the freshness check's own correctness: tests that fail if the check wrongly SKIPS a render it should not skip — a missed refresh must never pass silently. At minimum three tests, each proven red-first the same way FR-005's structural guard above is proven red-first (written and run against a deliberately-broken/naive version of the check — one that always short-circuits, or whose stamp comparison is missing the relevant condition — to confirm each test genuinely fails there, before the real implementation makes it pass):

  • (a) Template-change staleness test. Change a command template's source content (mutate a file under _get_command_templates_dir()'s tree, or the template_source_signature input it feeds) without touching cli_version or agent_keys, then call assess_global_agent_commands()/ensure_global_agent_commands() again. What it catches: a broken stamp comparison that only checks cli_version (or omits template_source_signature from the comparison entirely), which would short-circuit and leave the stale, pre-edit render on disk. Proof of non-vacuousness: run this test first against a stub/naive freshness check that omits the template_source_signature field from its match condition (or hard-codes a match) — confirm it fails (the stale render is wrongly reported healthy) — then against the real four-condition contract (data-model.md § Read/write contract) — confirm it passes.
  • (b) Version-change staleness test. Simulate a spec-kitty version bump (patch or monkeypatch _get_cli_version()/__version__ to return a different value than what the stored stamp carries) without changing any template content, then call assess_global_agent_commands()/ensure_global_agent_commands() again. What it catches: a broken stamp comparison that omits cli_version from the match condition (e.g. only compares template_source_signature), which would short-circuit on a version bump and never re-render even though the new version's templates/rendering logic may differ. Proof of non-vacuousness: run first against a stub/naive check with cli_version excluded from the comparison — confirm it fails — then against the real contract — confirm it passes.
  • (c) Destination-health staleness test. With a stamp that matches on all three source-side fields (cli_version, template_source_signature, agent_keys), delete, corrupt, or hand-edit one of the rendered destination command files (e.g. remove a file under ~/.claude/commands/, or overwrite one with garbage content), then call assess_global_agent_commands()/ensure_global_agent_commands() again. What it catches: exactly PLAN-ARCH-001's failure mode — a stamp-only short-circuit (source-side match alone, no destination-health check) that would report the agent healthy despite the missing/altered destination file, never repairing it. Proof of non-vacuousness: run first against a version of the check that short-circuits on stamp match alone, without calling _all_global_agent_commands_healthy() (agent_commands.py:296-305) — confirm it fails (the corrupted/missing destination is wrongly left unrepaired) — then against the real four-condition contract, which requires the destination-health check to also pass — confirm it passes (the render is triggered and the destination is repaired).

These three map directly onto the "Miss, stamp-side" and "Miss, destination-health-side" dispositions already enumerated in the Diff-cover section below — this subsection states them explicitly as the RED-FIRST proof Ruling 6 requires, rather than leaving that proof-of-non-vacuousness step implicit in the coverage enumeration.

NFR-003 (<2s CLI-operation charter standard): the freshness-check fix directly serves this standard for every spec-kitty invocation on the machine (not only the golden path's 12 calls) — a spec-kitty command that currently pays several seconds for an unconditional 104-template re-render on every call is the single largest concrete violator of the charter's <2s standard this investigation surfaced. This is supporting rationale, not a new requirement beyond FR-003/FR-005 (per NFR-003's own text).

Typer error-surface/CLI-contract risk: neither mechanism changes any command's --help text, argument surface, JSON envelope shape, or exit-code contract — the freshness check is purely an internal caching decision inside assess_global_agent_commands(), and the lazy-import trim changes when a module is imported, not what it registers once imported (the same app.command()/app.add_typer() calls still run, just deferred behind the lazy lookup). No test beyond the existing CLI-contract suite (tests/specify_cli/cli/) and this mission's own new regression guard is needed to cover this risk; no new risk is introduced to the Typer error-surface gate.

Actions evidence path (NFR-002/SC-003, Ruling 2)

This PR's diff touches tests/e2e/test_charter_epic_golden_path.py and tests/e2e/conftest.py directly, so the router's e2e filter group (.github/workflows/ci-router.yml — maps tests/e2e/ and tests/cross_cutting/) is selected as-is — no router change is needed or made, per Ruling 4. Concretely:

1. Open the PR from issue-4213-golden-path-nfr-budget targeting main with the full diff (fixture redesign + skip removal + lever-C fix + FR-005 guard). 2. The tests-e2e job (.github/workflows/ci-router.yml, name tests (e2e), 25-minute timeout, if: needs.changes.outputs.e2e == 'true') runs pytest tests/e2e -q on a real ubuntu-latest GitHub Actions runner. This single job's run IS the required real-Actions measurement — no separate/extra CI job needs to be added. 3. Where the ≤110s number gets recorded, so sk-review can verify it later: Adding --durations (or equivalent) to that one run: step in ci-router.yml's tests-e2e job is explicitly NOT proposed here — that would be a CI/router change, which Ruling 4 forbids. It would also not isolate the right number even if it were made: the tests-e2e job has exactly one run: step (.github/workflows/ci-router.yml:681-683) combining uv sync --frozen --all-extras and uv run --frozen pytest tests/e2e -q with no step boundary between them, and pytest tests/e2e -q collects and runs all 21 tests across 6 files in tests/e2e/ (not only test_charter_epic_golden_path), which itself performs no internal timing/logging of its own wall-clock cost — so a step-level Actions timestamp cannot isolate the golden path's own duration from uv sync plus the other 20 tests' combined runtime, which is not the quantity Ruling 2's hard, no-band ≤110s bar requires. Instead: add --durations=0 --durations-min=1.0 to pytest.ini's existing addopts line (pytest.ini:11, currently addopts = --tb=short) — pytest.ini, not pyproject.toml, is this repo's canonical pytest-config home (per the comment at pyproject.toml:228-236, which explicitly forbids reintroducing [tool.pytest.ini_options] there in favor of pytest.ini, guarded by tests/architectural/test_marker_registry_single_source.py). This is a pytest.ini change, not a .github/workflows/*.yml change, so Ruling 4 is respected.

Measuring the right quantity (corrected per post-tasks analysis, finding A1): an earlier draft of this section specified --durations=1. That mechanism measures the wrong thing — --durations=N reports only the single globally-slowest individual setup/call/teardown phase across the whole pytest tests/e2e -q run, never a per-test sum and never the suite total; applied to this test's own readiness-session numbers (call=73.58s, setup=29.36s, teardown=0.03s), it would print only the call-phase line, silently omitting the ~29.36s setup phase — understating the true cost against the hard ≤110s bar by roughly a quarter. --durations=0 --durations-min=1.0 fixes this deterministically rather than heuristically: --durations=0 removes the top-N count limit (show every phase at or above the minimum), and --durations-min=1.0 sets that minimum to 1.0s (pytest's default is 0.005s). Since the golden path's setup (~29s) and call (~74s) phases are each, standing alone, far above the 1.0s floor, both are guaranteed to print every time the test runs, independent of how many other tests are in the same invocation — unlike a bare --durations=N count limit, which only "probably" surfaces the right phases depending on how the rest of the suite happens to compare. The teardown phase (~0.03s) is expected to be filtered out by the 1.0s floor; this is an intentional, immaterial omission (~30ms against a budget with second-scale margins), unlike the setup-phase omission the original --durations=1 draft produced. Verified live this session (scratch pytest run, not committed to this repo): a fixture with a 1.3s setup / 1.6s call / 0.2s teardown, under --durations=0 --durations-min=1.0, printed exactly the setup and call lines and hid the teardown line — confirming the threshold behavior empirically. The --durations-min=1.0 floor also keeps this repo-wide-applying flag low-noise: the fast/unit test tiers are sub-second by definition (per pytest.ini's own marker description) and mostly disappear from the report entirely, avoiding the flood a bare --durations=0 (no minimum) would otherwise produce across the repo's much larger fast/unit suites.

pytest's own --durations terminal summary is written to the console regardless of -q and is not subject to per-test stdout capture, so it appears in the Actions log for every pytest invocation across the repo, including the tests-e2e job's pytest tests/e2e -q step, giving the golden-path test's own isolated wall-clock duration without any workflow-file edit. Because this mechanism can print more than one line for the golden path's nodeid, extracting the number requires summing every line matching that nodeid (normally setup + call; teardown is expected absent, filtered by the 1.0s floor) — with an actual calculation, not by eye: spec.md's own readiness-session evidence originally recorded 73.58 + 29.36 + 0.03 as "127.55s" instead of the correct 102.97s (see finding B1), which is exactly the manual-arithmetic failure mode this instruction exists to prevent. That summed duration is copied into (a) the PR description, in an explicit "Actions evidence" section stating the measured seconds (with the individual line values and their sum shown, not just a bare final number, so a reviewer can re-check the arithmetic) and a link to the run, and (b) an evidence line appended to this plan.md (a new ## Actions Evidence Log section, added by the implementer once the real measurement exists — not fabricated here, since no such run exists yet at plan time). This evidence step is human-reviewed only, not mechanically gated: no test, lint rule, or CI gate in this mission's own gate set (see Gate set below) asserts that the ## Actions Evidence Log section exists or contains a real, correctly-summed seconds figure — sk-review's accept gate must positively confirm that section exists, shows the individual duration lines plus their sum, and cites a real run URL (not merely that the PR is green) before treating NFR-002/SC-003 as closed. This reliance on human review is stated here as an explicit, acknowledged control, not left as an unnoticed gap. 4. STOP condition, stated explicitly: if the measured wall-clock time is in the 110-120s band, the mission STOPS and returns to the operator for a new, explicitly-scoped decision — this is never self-authorized as a pass, per Ruling 2. If it is ≥120s, the @pytest.mark.timeout(120) marker itself fires and the job goes red — an outright failure requiring further lever work, not a closable state at all.

Red-first per changed behaviour

FRWhat fails today (or is added) that would fail if the change were reverted
FR-001 (un-skip)test_charter_epic_golden_path is currently SKIPPED, not collected as a pass/fail — removing the skip decorator alone makes the test's own body (already written, already asserts the full operator journey) the red-first check: reverting the skip-removal re-skips the test, which is itself the observable regression this FR exists to prevent (and is exactly what C-005 requires stay loud, not silent).
FR-002 (fixture)Before the fixture change, the golden path measured 127.55s (readiness session, local, -p no:timeout) — over the 120s cap. After the fixture + lever-C changes land, the same test must pass within budget; reverting the fixture change alone (keeping the lever-C fix) should still show a measurable increase in the fixture's own setup phase duration (pytest's --durations=N breaks out setup from call), giving a directly attributable regression signal if this fixture is reverted independently.
FR-003 (startup trim)Reverting the lever-C freshness-check fix (restoring the unconditional _render_agent_commands() call for every key) should reproduce the pre-fix per-call cost measured in this plan's research pass — verified at implementation time by re-running the same cProfile-based measurement this plan used, before/after the fix, on the actual implementation diff (not merely inferred from this plan's own scratch measurement).
FR-005 (regression guard)Both guard halves are themselves the red-first check for a future regression: the structural half's own test (test_type_checking_guarded_jsonschema_import_is_detected-style parametrized check, per the #4409 pattern) is written to fail against a deliberately-reintroduced eager-render shape before the fix, proving the guard is non-vacuous, then passes once the real fix's shape matches. This is exactly the #4409 precedent's test_indented_module_scope_jsonschema_import_is_detected-equivalent self-mutation-testable pattern.

Diff-cover ≥90%

The ci-aggregate.yml diff-cover job (--fail-under=90, changed-critical-path-lines only) is held by ensuring every new/changed line in the four touched-or-added modules is exercised:

  • assess_global_agent_commands()'s new freshness-check branch: exercised directly by existing tests that already call ensure_global_agent_commands()/assess_global_agent_commands() (tests/ under the module's own mirror — locate via grep -rl assess_global_agent_commands tests/), extended with new targeted unit tests for each of the read/write contract's three dispositions / four conditions (data-model.md § Read/write contract, grown by PLAN-ARCH-001/PLAN-ARCH-002 — the two-branch enumeration this section previously carried predates those fixes and under-scoped the required tests, per round-2 fresh-sweep finding PLAN-FRESH-003): (1) Match — the stamp's stored triple matches the current triple AND _all_global_agent_commands_healthy() reports every configured destination healthy: short-circuits, no render. (2) Miss, stamp-side — the stamp is absent, or any of the three source-side fields (cli_version/template_source_signature/agent_keys) differs: falls through to today's render-then-diff, then writes a fresh stamp on success. (3) Miss, destination-health-side — the stamp's triple matches but _all_global_agent_commands_healthy() fails (a deleted, partially-materialized, or hand-edited destination file): also falls through to render-then-diff and re-stamps, exercised as its own distinct test from (2) since it is reached only through the fourth condition, not the stamp comparison. (4) Unreadable/malformed — the stamp file exists but fails to parse (wrong schema, legacy plain-bytes content from the pre-existing _VERSION_FILENAME shape, invalid encoding, or a torn write): treated identically to absent via the narrowly-scoped read-only catch, then overwritten with a fresh, correctly-shaped stamp on success — this disposition is REQUIRED test coverage, not optional, since every existing installation's on-disk stamp is guaranteed to hit it on the first post-upgrade read (see data-model.md's Read/write contract, "Unreadable/malformed" bullet, for why).
  • register_commands()'s lazy-import table: exercised by the existing CLI-contract test suite (tests/specify_cli/cli/), since every registered command must still resolve and run correctly through the new lazy path — this is a case where the existing test suite already provides the coverage as a side effect of testing command behavior, plus one new targeted test asserting the import-deferral itself (that a module is NOT imported until its command name appears in argv).
  • fresh_e2e_project's changed lines: exercised by the golden path test itself (single consumer, per C-006's Key Entity note) plus the new explicit C-006 assertions added inside the fixture.
  • tests/performance/test_cli_startup_agent_commands_freshness.py (new file): test files are typically excluded from the diff-cover critical-path denominator (mirroring how #4409's own test file is not required to test itself) — confirm this at implementation time against ci-aggregate.yml's exclusion logic (the "census-dead surface" exclusion noted in that workflow) rather than assuming.

Campsite-clean

None, with reason: the surfaces this mission touches (assess_global_agent_commands(), register_commands(), fresh_e2e_project) do not carry pre-existing Sonar findings or over-length-function debt that this mission's own diff would otherwise have to work around or grow past (verify at implementation time via scripts/ci/sonarcloud_branch_review.sh, but nothing found during this planning pass's reading of these specific functions suggests a debt class blocking the functional change). Per the charter's tension-reconciliation order, smallest-viable-diff picks the file set first here, and no domain-matched debt was identified inside that file set to fold in ahead of the functional change.

PR shape

ONE PR for the whole mission, per spec-kitty's default (sk-design/references/design-pipeline.md §3a) — NOT tk's one-PR-per-WP rule. The PR closes both #4213 and #4211 (SC-004, FR-004): the PR description states Closes #4213 and Closes #4211, and explicitly states the shared root cause (budget overrun, not a hang — per the Evidence section of spec.md) so a reviewer does not need to re-derive which framing was correct.

Reflexivity

This change touches spec-kitty's own CLI startup construction (main_callback, register_commands) and a test fixture that exercises spec-kitty init — the exact machinery every other in-flight mission's agents (including this mission's own orchestrator) run against right now, every time they invoke the spec-kitty CLI. What happens to missions currently in flight when this change lands: nothing breaking, by design — neither lever changes any command's flags, JSON envelope shape, exit codes, or --help text (see Lever C's "Typer error-surface/CLI-contract risk" note above); the freshness-check fix only changes whether a redundant re-render happens, never what gets rendered or whether the global command directory ends up in the same final state. A running mission's agent would, if anything, observe faster spec-kitty invocations once this PR merges to main, with identical behavior otherwise. The one small user-visible risk is: an agent that switches to a DIFFERENT agent-config key mid-session, in a way that relies on ensure_global_agent_commands()'s previous "render all 13 agents regardless" behavior to have already warmed a not-yet-configured agent's commands, could see a first-invocation cost for that newly-relevant agent that it wouldn't have seen before — the freshness-check fix here is deliberately designed to NOT narrow which agents get rendered (see Lever C's "preserves the existing design intent" note), only to skip re-rendering when nothing changed, so this risk does not actually apply to the chosen mechanism; it would only apply to the "scope to configured agents" alternative mechanism this plan considered and did not choose (see research.md).

Gate set for this mission

Verified against this checkout's actual .github/workflows/*.yml files today (not a cached number):

  • ruff check . and ruff format --check . — always-on (ci-router.yml's ruff job, ci-quality.yml's Lint job). Applies; this mission's Python changes must pass both, including ruff check --select TID251 . (the separate import-linter job, also always-on) since TID251 is repo-wide-enforced across tests/ — no new hashlib/click.exceptions usage is introduced by this plan's changes, so no new TID251 violation is expected.
  • uv lock --check (ci-router.yml's uv-lock job and ci-quality.yml's equivalent, always-on) — applies trivially; no dependency added or changed by this plan.
  • terminology guard (tests/architectural/test_no_legacy_terminology.py, always-on) — applies trivially; no prose changes touch the two retired terms it gates (ceremony/status-writing).
  • layer-rules (tests/architectural/test_layer_rules.py, test_pyproject_shape.py, always-on) — applies trivially; no new cross-layer import is introduced (all changes stay inside specify_cli).
  • archive-freeze (tests/architectural/test_archive_root_byte_identical.py, always-on) — applies trivially; no archived kitty-specs/** content is rewritten.
  • regen-check (spec-kitty regen --check, always-on) — applies trivially; no packs/built-in/ template changes.
  • architectural-heavy battery (.github/workflows/ci-router.yml, code-scoped, triggered by ANY named src-backed filter group matching, including cli — see Router-filter note below) — applies, since lever C's changes to src/specify_cli/cli/commands/__init__.py fall under the cli filter group (src/specify_cli/cli/**).
  • Router-filter note (informational, corrected per plan-review finding PLAN-VERIFY-004): src/specify_cli/runtime/agent_commands.py IS covered by the next filter group (.github/workflows/ci-router.yml:116-119, matching src/specify_cli/runtime/*; the same path is also duplicated by the specify_cli_runtime group, ci-router.yml:160-163). The next group's output participates in the unmatched catch-all's own union computation (ci-router.yml:218), so a diff touching only that file sets next=true and does NOT trip the fail-closed unmatched catch-all (any_src == 'true' && matched == 'false') — it is a normally-matched change, not an unmapped one. It also directly gates the architectural-heavy battery via needs.changes.outputs.next == 'true' (ci-router.yml:523), among other jobs keyed on that same output. A future, narrower change touching only runtime/agent_commands.py therefore selects next's own shard plus architectural-heavy normally — it does not force a full-mode run and does not need any router change. For THIS mission's own PR the practical gate selection is unaffected either way (the diff also touches tests/e2e/* and src/specify_cli/cli/commands/__init__.py, both already named), so this correction changes only the note's own accuracy, not this PR's actual gate set.
  • tests-cli module shard (ci-modules.yml/module-tests.yml, selected by the cli filter group) — applies; targeted local run required before pushing (see Baseline method).
  • tests (e2e) (ci-router.yml, selected by the e2e filter group) — applies; this IS the mission's own closure mechanism (see Actions evidence path).
  • ci-aggregate.yml's diff-cover job (--fail-under=90) — applies; see Diff-cover section above.
  • make test-fast + blast radius (tests/unit tests/status tests/cli tests/specify_cli/runtime, per CLAUDE.md, plus this diff's own blast radius: tests/e2e/, tests/performance/) — applies; see Baseline method below.
  • Nightly performance-marked lane (.github/workflows/ci-nightly.yml's performance-and-e2e job) — applies to FR-005's wall-clock half, but does not gate this PR (nightly/dispatch-triggered only, confirmed via tests/architectural/test_performance_marker_guard.py's own assertion that no pull_request-triggered workflow selects -m performance).

Gates named by CLAUDE.md/the overlay that do NOT apply here, with reasons (verified against the actual workflow files, not assumed):

  • Kernel/mission-loader coverage floors: no such gate exists as a distinct, separately-enforced threshold in this checkout's workflows. module-tests.yml collects per-shard coverage as evidence only (--cov-report=xml) with no --cov-fail-under in that workflow; the only PR-blocking coverage threshold found is ci-aggregate.yml's diff-cover --fail-under=90 (already included above). CLAUDE.md's "90%+ test coverage for new code" (charter Technical Standards) is served by that same diff-cover gate, not a separate kernel-specific floor.
  • Contextive glossary gate: ci-router.yml's glossary filter group maps only src/glossary/**; this mission touches none of that path, so no glossary-gated job is selected by this diff.
  • TID251: not a separate gate beyond what's already listed above (import-linter job / ruff check --select TID251 .) — already included, not omitted.
  • Bandit / pip-audit: no workflow in .github/workflows/*.yml runs either tool in this checkout (grepped bandit/pip-audit/pip_audit, zero hits) — not a gate that exists here to apply or skip.
  • uv.lock freshness: already included above as uv-lock (not omitted; listed as applying trivially).
  • Commitlint: ci-router.yml's commit-msg job runs git log --format=%s ... || true with no assertion after — it is informational only (prints commit subjects), not an enforcing commitlint gate in this checkout. Does not apply as a blocking gate (the mission's own commit still follows Conventional Commits per spec-kitty safe-commit's own validation, which is a local/commit-time check, not this CI job).
  • Markdown lint: ci-router.yml's markdownlint job runs npx --yes markdownlint-cli2 "*/.md" || true — the trailing || true makes this job always exit 0 regardless of findings. It runs but cannot fail the PR; does not apply as a blocking gate.
  • Doctrine schema freshness: covered by regen-check (already included above as applying trivially) — no separate doctrine-schema-specific gate exists beyond that and the pack-manifest regen gate (spec-kitty doctrine regenerate-graph --check, in packs.yml), which is not selected since no packs/ content changes.

Baseline method

Per CLAUDE.md's §Test policy / blast-radius rule and the charter's §Pre-existing Failure Reporting Rule:

1. Commit to baseline against: the merge-base of issue-4213-golden-path-nfr-budget against origin/main (verify with git merge-base issue-4213-golden-path-nfr-budget origin/main at implementation time — do not assume it is 6b4164dbf; that SHA is the readiness session's checkout point, which may not be the current merge-base if main has advanced since). If the merge-base differs from 6b4164dbf, prefer the actual merge-base; note the discrepancy in the implementation's own tracer entry if one exists. 2. Exact commands (run from the repository root checkout, using the hand-built .venv, never bare uv run): ``bash .venv/bin/python -m pytest tests/unit tests/status tests/cli tests/specify_cli/runtime -q -m "(fast or unit)" .venv/bin/python -m pytest tests/e2e -q .venv/bin/python -m pytest tests/performance -q -m "not performance" ` (the tests/performance blast-radius run explicitly deselects the nightly-only performance-marked wall-clock tests via -m "not performance", consistent with how make test-full itself deselects them for the parallel pass — the structural half of FR-005's guard is NOT performance-marked and runs here.) 3. Test surface, per CLAUDE.md's blast-radius rule: make test-fast's baseline directories (tests/unit tests/status tests/cli tests/specify_cli/runtime) plus this mission's own blast radius — tests/e2e/ (both levers touch it directly) and tests/performance/ (FR-005's guard lands there). tests/architectural/ in full is NOT required (no pytest.ini/pyproject.toml/conftest/marker/packaging change), but the four always-on architectural gates listed in the Gate set above (terminology, layer-rules, archive-freeze, plus TID251 via import-linter) are cheap enough to run locally as a sanity check before pushing. 4. Distinguishing pre-existing red from introduced red: run the exact same three commands above against the merge-base commit (via git worktree add or PYTHONPATH override against a separate checkout of that SHA — never git checkout on this working tree per the "one checkout, many agents" rule) before making any change, and record pass/fail counts. Any failure present at the merge-base and still present after this mission's changes is pre-existing; any failure new relative to that baseline is introduced and must be fixed before the PR is marked ready. Per the charter's §Pre-existing Failure Reporting Rule`, the ORCHESTRATOR opens the GitHub issue for any pre-existing failure found — not the WP-implementing agent. The implementing agent's job is to run this comparison, classify each failure, and hand the pre-existing set to the orchestrator with the command run, the failure summary, and the comparison evidence; it does not file the issue itself and does not treat an unfiled pre-existing failure as license to skip reporting it.

Tracer files

See tracer-approach.md and tracer-design-decisions.md for entries appended by this planning pass, in particular the Lever A/Lever C mechanism-choice rationale and the CLI-startup profiling investigation that grounds both.