Implementation Plan: Source-aware unblock refusal message

Branch: issue-3937-blocked-planned-refusal-message | Date: 2026-09-17 | Spec: spec.md Input: Feature specification from kitty-specs/blocked-planned-refusal-message-01M2Q4E0/spec.md

Summary

Make the move-task --to planned refusal emitted by _guard_planned_rollback (src/specify_cli/cli/commands/agent/tasks_transition_core.py) source-aware, so a blocked/canceled/done work package is told its real legal targets (e.g. blocked → resume via --to in_progress) instead of the misleading "requires review feedback". The guard's allow/deny logic is unchanged: every --to planned move without a valid, non-empty review-feedback file is still refused, for all source lanes, with and without --force. This is a message-only change plus a non-vacuous regression battery; the state machine is not touched.

Technical approach (locked by grounding): inside the existing missing-feedback branch of _guard_planned_rollback, branch the message string on whether planned is reachable from the source lane — Lane.PLANNED in wp_state_for(resolve_lane_alias(req.old_lane)).allowed_targets():

current review-feedback message verbatim (preserves the pinning test and legitimate review-rollback guidance).

legal-targets message naming allowed_targets() for that lane, and for blocked specifically point to --to in_progress as the resume path. No mention of review feedback.

  • reachable (in_review, in_progress, approved, genesis): keep the
  • not reachable (blocked, canceled, done, uninitialized): emit a

Both arms return RefuseExit1(...) — there is no new early-return and no path that lets a source reach build_transition_plan/the state machine. The source lane is read only to shape the message, never to decide whether to refuse.

Technical Context

Language/Version: Python 3.11+ Primary Dependencies: typer, rich (existing; no new dependency added or changed) Storage: N/A (no persistence change; append-only status event log untouched) Testing: pytest (unit-level, against the pure decision core decide_transition / _guard_planned_rollback) Target Platform: Linux/macOS CLI (spec-kitty command surface) Project Type: single (CLI adapter layer, src/specify_cli/) Performance Goals: N/A — a refusal message; no hot path, no measurable latency budget Constraints: Message-enrichment only (spec C-001/C-002); _guard_planned_rollback stays unconditional and force-proof; _GUARDS order/identity unchanged; wp_state.py allow/deny and fsm_parity_baseline.jsonl byte-unchanged; canonical terminology (no forbidden legacy terms) Scale/Scope: One source file changed (guard message), one test file extended (regression matrix). No new module, symbol, CLI flag, or public surface.

Constitution Check

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

Charter (v1.4.0) alignment for this change:

machine's own allowed_targets() accessor; the message duplicates no allow/deny rule. PASS.

(src/specify_cli/); the state-machine module (status/wp_state.py) is not touched, so no seam is crossed. PASS.

matrix rows (esp. the flagless done → planned resurrection tripwire) are written RED against a deliberately-holed guard and proven to fail before the fix. The prior attempt was cut precisely because its test was vacuous (--force nowhere; done/canceled/genesis never a source). PASS by construction — see WP tasks.

is asserted observably (resulting lane + emitted-event absence), not by message substring alone. PASS.

reachability partition is the smallest change that fixes the reported defect and the two adjacent misleading cases (canceled/done), chosen over an FSM change by explicit operator decision 01M2Q4F897ANB84HA6EHA9DG98. No guard refactor into source-scoped arms. PASS.

vocabulary; guarded by test_no_legacy_terminology.py. PASS.

  • Single canonical authority — legal targets are read from the state
  • Architectural alignment — change stays in the CLI adapter layer
  • ATDD / red-first (DIRECTIVE_034/041, Standing Order #4) — the regression
  • Non-vacuous gate (DIRECTIVE_043, Standing Order #5) — the force-proofness
  • Smallest-viable-diff vs Boy-Scout (RECONCILE_CHANGE_SCOPE_TENSIONS) — the
  • Terminology canon — new prose uses canonical lane names and --to

No charter conflicts. No supply-chain section applies (no dependency add/upgrade/removal).

Project Structure

Documentation (this mission)

kitty-specs/blocked-planned-refusal-message-01M2Q4E0/
├── plan.md              # This file
├── research.md          # Phase 0 output — grounding synthesis + design decision
├── data-model.md        # Phase 1 output — lanes, reachability partition, request fields
├── quickstart.md        # Phase 1 output — how to reproduce & verify
├── contracts/
│   └── planned-rollback-message.md   # behavioral contract for the refusal message
└── tasks.md             # Phase 2 output (/spec-kitty.tasks — NOT created here)

Source Code (repository root)

src/specify_cli/cli/commands/agent/
└── tasks_transition_core.py          # CHANGED: _guard_planned_rollback message (source-aware)

src/specify_cli/status/
└── wp_state.py                       # READ-ONLY: wp_state_for(...).allowed_targets() (unchanged)

tests/specify_cli/cli/commands/agent/
└── test_tasks_transition_core.py     # EXTENDED: reachability × force regression matrix

Implementation Concern Map

ConcernDetailOwning WP
IC-1 Reachability helperCompute "is planned reachable from source lane?" from wp_state_for(resolve_lane_alias(old_lane)).allowed_targets() — pure, read-only, no allow/deny duplication.WP01
IC-2 Source-aware messageBranch the missing-feedback message: reachable → existing review-feedback text (verbatim); unreachable → legal-targets text (blocked names --to in_progress). Both return RefuseExit1.WP01
IC-3 Force-proof invariantNo new early-return; no source reaches the state machine; the four feedback-field checks unchanged; _GUARDS order unchanged.WP01
IC-4 Regression battery{done, canceled, genesis, blocked, in_review} × {none, --force} all refuse, lane unchanged; flagless done → planned emits no forced-rewind event; positive control in_review + valid feedback → planned; fabricated feedback on blocked still refused. Written RED-first.WP01
IC-5 Terminal/empty-target wordingdone/canceled (empty allowed_targets) and uninitialized produce correct prose, not an empty list or a raise.WP01

Parallel Work Organization

Single work package. The change is one guard-message edit plus its co-located regression tests in one test file; the concerns above are tightly coupled (splitting the message from its force-proof tests would let a vacuous slice merge). One lane, one worktree.

and tests/specify_cli/cli/commands/agent/test_tasks_transition_core.py): IC-1 … IC-5.

  • WP01 (owns src/specify_cli/cli/commands/agent/tasks_transition_core.py

Complexity Tracking

No new complexity introduced. The added branch is a single reachability check plus two message strings; _guard_planned_rollback stays well under the complexity ceiling (15). No suppressions.