spec-kitty / spec-kitty/spec-kitty
regression of #3830 in 3.2.6rc4: custom mission types stay undrivable via next — _composition_dispatch_inputs still returns (None, None) for action_sequence members, executor raises "profile_hint is required when no action default exists for qa/discovery"
- Dominant language
- Python
- Stars
- 1.6k
- Forks
- 165
- Avg merge
- 14h 52m
- Merged PRs (30d)
- 303
Description
## Summary
`spec-kitty next` cannot drive even one step of an org-pack custom mission type through the governed composition path. For every step of such a type the charter-resolved `action_sequence` contains the action (custom types project their runtime template's steps), so the short-circuit at `runtime/next/runtime_bridge_composition.py:318-327` fires on **every** post-start dispatch and returns `(profile_hint=None, contract=None)` — without ever consulting the step's bound `agent_profile:`, the very channel its own prohibition comment (`specify_cli/mission_step_contracts/executor.py:68-71`) says custom missions MUST use. The executor then raises at `executor.py:292-295` (its only fallback, `_ACTION_PROFILE_DEFAULTS`, is a built-in-only table by policy), `next --result success` returns `kind=BLOCKED`, and the mission is undrivable through the governed loop. This is a **regression report**: #3830 was closed COMPLETED 2026-09-03 — part of the #3830/#3831/#3832 trio reported "fixed together in a single PR" — yet the code slice is verifiably absent from the installed `3.2.6rc4` build and the guard fired live on 2026-09-13 (verbatim record below). The fix is small: in the short-circuit branch, fall back to `_rb._resolve_step_agent_profile(run_dir, step_id)` before nulling `profile_hint`.
## Environment
- **CLI:** `spec-kitty-cli version 3.2.6rc4` (run as `SPEC_KITTY_NO_UPGRADE_CHECK=1 spec-kitty --version`)
- **Pack clone revision:** `1418fc8 chore(int-e2e-qa-run-01M1F2N8): capture mission retrospective` (clone `/home/jeroennouws/dev/SK-missions/qa-pack`; remote `origin https://github.com/spec-kitty/spec-kitty-qa-pack.git`)
- **Python:** `Python 3.14.6` (`python3 --version`)
- **Host OS:** `Linux ai-workplace 7.1.5-201.fc44.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Jul 28 14:16:30 UTC 2026 x86_64 GNU/Linux`
- **Discovery:** found during a governed QA mission (`registry-foundation-qa-01M2DW4R`) on a consumer project, via the pack's own command templates + `spec-kitty doctrine asset path qa-result-ingest`
- All `file:line` citations below were opened and read verbatim in the installed copy under `/home/jeroennouws/.local/share/uv/tools/spec-kitty-cli/lib/python3.14/site-packages/` (referred to as `/`) on 2026-09-15.
## Steps to reproduce
A live re-run of the exact `next --result success` sequence would mutate the consumer repo's live mission state (state-mutating command) — deliberately not performed under the discovery session's read-only mandate. The recorded empirical evidence stands in its place (`.kittify/runtime/programs/phase-0/decisions.md`, entry **DEC-QA-02** (L124-172); mission `registry-foundation-qa-01M2DW4R`, `mission_type: qa`, CLI 3.2.6rc4 + installed qa-pack):
1. call 1 — `next --mission registry-foundation-qa-01M2DW4R` (query): `kind=query preview_step=discovery`
2. call 2 — after the discovery work was committed (`4450a6e`), `next --mission … --result success`: `kind=step action=discovery` with a **composed stub prompt** (org tier, `origin.mission_path=.../qa-pack/missions/qa`) — succeeded only because the run dir did not exist yet (pre-run legacy path, see Root cause).
3. call 3 — `next --mission … --result success` again, now with a live run dir: `kind=BLOCKED` with `guard_failures = "composition failed for qa/discovery: profile_hint is required when no action default exists for qa/discovery"` — verbatim.
Same defect class already on 3.2.6rc1 per the pack's own FR-013 evidence (`kitty-specs/qa-mission-type-01M03X2C/evidence/`: "No step contract found for mission/action qa/discovery") and the pack's SK-126 ledger; and the pack's `CONVENTIONS.md` entry **(n)** (L580-607) records that installing the seven step-contract files clears the first block only to uncover *this* second one, reproduced "twice, byte-for-byte" (`kitty-specs/pack-step-contracts-01M04BKT/evidence/README.md`, cited there).
Deterministic read-only confirmation available to any maintainer, in the installed `/` copy of 3.2.6rc4: read `_composition_dispatch_inputs` (L318-327) alongside `_should_dispatch_via_composition` (L180-203) and `specify_cli/mission_step_contracts/executor.py:68-76 + 282-295` (all quoted in Root cause). Unit form: with a frozen template whose step binds `agent_profile`, monkey-patch `resolve_mission_type_context` to return a context whose `action_sequence` contains the action and call `_composition_dispatch_inputs` → it returns `(None, None)` even though `_resolve_step_agent_profile` on the same args returns the bound profile.
## Expected vs Actual
**Expected:**
- Every step of a registered custom mission type dispatches through composition with the profile bound on the step (`agent_profile:` in the frozen runtime template); `next --result success` returns a dispatchable `kind=step`, and the guards enforce the artifact manifest.
**Actual:**
- Every dispatch of a custom-type step with a live run dir returns `kind=BLOCKED` with the verbatim guard string `"composition failed for qa/discovery: profile_hint is required when no action default exists for qa/discovery"`. The operator is forced to drive the mission PER-STEP via the pack's command templates + step contracts (the DEC-QA-02 decision); `next` degrades to a query/state oracle and the guard-enforced artifact manifest is lost — the manifest's blocking entries (review's `test-report.md` + `qa-traceability.yaml`, accept's `qa-coverage.json`) were not enforced by any working next-guard but enforced by step briefs + the accept-gate review instead.
- The paradox (DEC-QA-02 L148-150): the registration that makes `specify --mission-type qa` succeed is exactly what triggers the block — no configuration of the pack avoids it while the `qa` type stays registered.
- Not critical, and why it still matters: missions can still be completed via the documented per-step path (the workaround used here), and mission state is never corrupted; but the CLI's own control loop is unusable end-to-end for *any* org-tier custom mission type.
## Root cause
Two CLI halves that never meet, confirmed by direct read of every function below in the installed 3.2.6rc4 copy:
1. **The governance half** resolves a custom mission type's `action_sequence`: `charter/activation/mission_type_profiles.py:567-673` (`resolve_mission_type_context`); for custom types the sequence is the projection of the runtime template's steps (its own note at L956-961: "`MissionTypeRepository._load` overlays `project_action_sequence(steps)`"). So for the `qa` type **every** action (`discovery…accept`) is a sequence member.
2. **The composition half** then short-circuits on exactly that membership and nulls the profile — an optimization that serves built-in types, whose profiles come from `_ACTION_PROFILE_DEFAULTS`:
- `runtime/next/runtime_bridge_composition.py:303-341` — `_composition_dispatch_inputs`. The offending branch L318-327: `action_sequence = resolve_mission_type_context(repo_root, mission_type=mission).action_sequence` (L323-325) then `if action in action_sequence: return None, None` (L326-327) — profile_hint AND contract nulled without ever consulting the frozen template's bound `agent_profile`. The fallback leg (reached only when the action is NOT in the sequence), L333-341, returns `_rb._resolve_step_agent_profile(run_dir, step_id)` + `_rb._resolve_runtime_contract_for_step(...)` — the very read the short-circuit skips.
- `runtime/next/runtime_bridge_composition.py:149-204` — `_should_dispatch_via_composition`: for a live run dir the SAME charter check returns `True` (L186-190: action ∈ action_sequence → dispatch via composition); and `run_dir is None -> return False` (L200-201; docstring L166-168 and L197-199: "e.g., on the very first decide_next call before the run is started") — explaining why the one pre-run dispatch produced the legacy stub prompt while every post-run dispatch blocks.
- `runtime/next/runtime_bridge_composition.py:206-230` — `_resolve_step_binding` reads `step.agent_profile` from the frozen template (the binding that exists but is never consulted on the short-circuit leg).
- `runtime/next/runtime_bridge_composition.py:551-582` — `_dispatch_via_composition` builds `StepContractExecutionContext(..., profile_hint=profile_hint, ...)` (L561-562), resolves `selected_contract = contract or get_runtime_contract_registry().lookup("custom:{mission}:{action}")` (L572-574), and converts the executor's `StepContractExecutionError` into the observed guard string at L582: `return [f"composition failed for {mission}/{action}: {exc}"]`.
- `specify_cli/mission_step_contracts/executor.py:68-76` — the built-in-only defaults table with its own prohibition comment (verbatim L68-71): "this table is for built-in missions ONLY." / "Custom missions MUST resolve profile_hint via PromptStep.agent_profile;" / "expanding this table for arbitrary custom missions is forbidden." `_ACTION_PROFILE_DEFAULTS` (L72) has only `("software-dev", ...)` entries visible at L73-75 (`specify/plan/tasks`; research/documentation likewise further down).
- `specify_cli/mission_step_contracts/executor.py:282-295` — `_resolve_profile_hint`: returns `context.profile_hint` if set (L287-288); else `default = _ACTION_PROFILE_DEFAULTS.get((contract.mission, contract.action))` (L289); else raises `StepContractExecutionError("profile_hint is required when no action default exists for " f"{contract.mission}/{contract.action}")` (L292-295). For `(qa, discovery)` there is no entry → raise → the observed BLOCKED.
3. Meanwhile the pack side binds a profile on every step: `qa-pack/missions/qa/mission-runtime.yaml:1-9` header comment + steps L14-56 — seven steps each with `agent-profile: researcher-robbie` / `qa-strategist-quinn` / `qa-test-designer-tessa` / `qa-test-executor-elena` / `qa-reporter-rhea`, and `contract_ref` deliberately set on none. The step's own bound profile — the documented custom-mission channel — is readable (`_resolve_step_agent_profile` → `_resolve_step_binding`) but never read for a sequence-member action, so the dispatch always dies at the raise.
Corroboration: the pack's independent `CONVENTIONS.md` entry **(n)** (L580-607) documents this exact defect and states the same fix condition, verified against the same installed package — strong evidence the defect is CLI-side and pack-unavoidable.
Record-citation note (kept honest): DEC-QA-02's phrase "every qa step.yaml sets `in_action_sequence: true` (required for the create-time gate)" does **not** correspond to the current pack revision — there are no `step.yaml` files and no `in_action_sequence` field anywhere under `missions/qa/` (grep: zero matches); sequence membership derives from the `mission-runtime.yaml` steps projection (`charter/activation/mission_type_profiles.py:956-961`). The *conclusion* is nonetheless correct and was re-verified: every step is a member. Stale record citation, conclusion re-verified.
Regression status vs the trio: #3830 (verbatim match of this defect — its body quotes the same two code sites, the same `(None, None)` analysis, the same #505 prohibition comment) was closed COMPLETED 2026-09-03. **Reality check in this session:** the short-circuit is STILL present in the installed `spec-kitty-cli 3.2.6rc4` build (`_composition_dispatch_inputs` L318-327 read verbatim 2026-09-15) and fired live on 2026-09-13 — i.e. #3830 was closed COMPLETED but is NOT fixed in the rc4 build. [#INFERENCE: the "single PR" either never shipped this slice or regressed; note #3831 of the same trio is demonstrably still unfixed too — its accept-time symptom fires today, see the corroboration comment on #3831 — supporting "never shipped / partially shipped".]
## Proposed fix + suggested test
In `runtime/next/runtime_bridge_composition.py::_composition_dispatch_inputs`: when the charter short-circuit fires (action ∈ action_sequence), do not return `(None, None)` unconditionally — fall through to `_rb._resolve_step_agent_profile(run_dir, step_id)` first (the same frozen-template read the non-member leg already uses), return `(profile, None)` when it yields a bound profile, and null only when the frozen template binds nothing either. This is exactly the pack's stated removal condition (`CONVENTIONS.md` **(n)** L600-601: "changes `_composition_dispatch_inputs` to fall back to the step's own `agent_profile:` binding before nulling `profile_hint`, when the charter short-circuit is what fired"). It preserves built-in behavior: built-in steps carry no frozen-template `agent_profile` bindings, so they keep receiving `(None, None)` and the `_ACTION_PROFILE_DEFAULTS` table stays built-in-only (the #505 prohibition is respected — the fix routes custom profiles through the `PromptStep.agent_profile` channel that prohibition comment itself names).
Suggested regression tests (pytest, runtime/next suite):
- (a) custom type, step ∈ action_sequence, frozen template binds `agent_profile: x` → `_composition_dispatch_inputs` returns `("x", ...)` and the end-to-end `_dispatch_via_composition` no longer emits the `profile_hint is required` guard;
- (b) same but the template binds nothing → still `(None, None)` → the executor raises exactly as today (fail-closed preserved);
- (c) built-in `software-dev` regression: profile resolution unchanged (table hit);
- (d) member leg with `contract_ref` set → `_resolve_runtime_contract_for_step` consulted as on the non-member leg.
## Acceptance criteria
1. With a custom (org-tier) mission type whose charter-resolved `action_sequence` contains the action and whose frozen runtime template binds `agent_profile: x` on the step, `_composition_dispatch_inputs` returns a non-None `profile_hint` (`"x"`), and the end-to-end `_dispatch_via_composition` no longer emits `"composition failed for qa/discovery: profile_hint is required when no action default exists for qa/discovery"`; `next --result success` yields a dispatchable `kind=step` instead of `kind=BLOCKED`.
2. Fail-closed preserved: a sequence-member step whose frozen template binds no profile still resolves `(None, None)` and the executor raises exactly as today (test (b)).
3. Built-in behavior unchanged: `software-dev` dispatch still resolves via `_ACTION_PROFILE_DEFAULTS` (test (c)); the table is not expanded and the built-in-only prohibition at `executor.py:68-71` (#505) is respected — custom profiles route via the documented `PromptStep.agent_profile` channel.
4. The member leg with `contract_ref` set consults `_resolve_runtime_contract_for_step` exactly as the non-member leg does (test (d)).
5. Regression tests (a)-(d) are added to the runtime/next pytest suite and pass.
6. End-to-end: an org-pack mission type with profile-bound steps (e.g. the qa type's seven `missions/qa/mission-runtime.yaml` steps) is drivable through the governed `next` loop without the per-step command-template workaround, with the guard-enforced artifact manifest intact.
## Context
- **Discovery:** found during the governed QA mission `registry-foundation-qa-01M2DW4R` on a consumer project (CLI 3.2.6rc4, pack rev `1418fc8`). Exposing runtime commands: `spec-kitty next --mission registry-foundation-qa-01M2DW4R` (query), then `spec-kitty next --mission registry-foundation-qa-01M2DW4R --result success` (calls 2 and 3 above, executed 2026-09-13), with the pack's step contracts installed; all spec-kitty invocations run with `SPEC_KITTY_NO_UPGRADE_CHECK=1`. The pack's command templates (`missions/qa/command-templates/*`) became the per-step workaround; the guard string above is the failure record.
- **Upstream cross-links (regression):** #3830 — CLOSED (COMPLETED 2026-09-03), the original report of this exact defect; this issue is its regression/re-open request. #3831 — OPEN, same trio (org-tier type invisible to the loader; still fires — see the corroboration comment posted there). #3832 — CLOSED, same trio (setup-plan applies a software-dev-shaped substantive gate to every mission type). The trio was authored 2026-09-01 by account `MOES-Media` (member) under mission `custom-mission-type-second-class-citizens`; the author commented the three were "fixed together in a single PR" — that claim does not hold for the installed `3.2.6rc4` slice, verifiably (see Root cause).
- **Alternative action:** requesting re-open of #3830 with this evidence is equally acceptable — maintainer's preference; this issue carries the full evidence package either way.
- Severity rationale: high — the CLI's own control loop is unusable end-to-end for *any* org-tier custom mission type while mission state itself never corrupts; the documented per-step path remains the workaround.
Contributor guide
Research direction
Read runtime/next/runtime_bridge_composition.py, especially _composition_dispatch_inputs and _resolve_step_binding, then inspect executor.py:_resolve_profile_hint and qa-pack/missions/qa/mission-runtime.yaml. Add the regression test described in the issue for a sequence-member custom step with a bound profile. Done means post-start `next --result success` returns a dispatchable step instead of the cited BLOCKED error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100