specfuse-loop 0.3.18__tar.gz → 0.3.20__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. {specfuse_loop-0.3.18/specfuse_loop.egg-info → specfuse_loop-0.3.20}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.20/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/methodology.md +7 -1
  5. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/lint_plan.py +25 -0
  6. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/loop.py +165 -8
  7. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20/specfuse_loop.egg-info}/PKG-INFO +1 -1
  8. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval_intermediate_wiring.py +11 -0
  9. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval_terminal_wiring.py +111 -0
  10. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_plan_errors.py +40 -0
  11. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_files_changed_guard.py +16 -0
  12. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_orchestration.py +32 -5
  13. specfuse_loop-0.3.18/specfuse/loop/data/VERSION +0 -1
  14. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/LICENSE +0 -0
  15. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/NOTICE +0 -0
  16. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/README.md +0 -0
  17. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/setup.cfg +0 -0
  18. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/__init__.py +0 -0
  19. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/_filelock.py +0 -0
  20. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/_miniyaml.py +0 -0
  21. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/adopt_feature.py +0 -0
  22. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  23. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  24. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  25. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/getting-started.md +0 -0
  26. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/skills.md +0 -0
  27. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/gitignore.snippet +0 -0
  28. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/roadmap.template.md +0 -0
  29. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  30. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/never-touch.md +0 -0
  31. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/result-contract.md +0 -0
  32. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  33. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  34. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/event.schema.json +0 -0
  35. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  36. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  37. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  38. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  39. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/templates/GATE.template.md +0 -0
  40. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  41. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/templates/WU.template.md +0 -0
  42. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/verification.yml.example +0 -0
  43. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/gate_eval.py +0 -0
  44. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/gh_backend.py +0 -0
  45. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/gh_features.py +0 -0
  46. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/scaffold.py +0 -0
  47. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/validate_event.py +0 -0
  48. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/SOURCES.txt +0 -0
  49. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  50. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/entry_points.txt +0 -0
  51. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/requires.txt +0 -0
  52. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/top_level.txt +0 -0
  53. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_adopt_feature.py +0 -0
  54. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  55. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_attempt_outcome_emission.py +0 -0
  56. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autoclose_deferral_visibility.py +0 -0
  57. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync.py +0 -0
  58. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync_consent.py +0 -0
  59. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync_firstrun.py +0 -0
  60. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync_plugin.py +0 -0
  61. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_backend.py +0 -0
  62. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bash_routing.py +0 -0
  63. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  64. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  65. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bookkeeping_no_verify.py +0 -0
  66. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_claude_resolution.py +0 -0
  67. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_close_wu_self_reference.py +0 -0
  68. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_closing_deliverable_guard.py +0 -0
  69. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_console_encoding.py +0 -0
  70. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_cost_tracking.py +0 -0
  71. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_degraded_oracle.py +0 -0
  72. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_deliverable_presence_gate.py +0 -0
  73. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_doctor.py +0 -0
  74. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_driver_integration.py +0 -0
  75. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_driver_lock.py +0 -0
  76. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_duration_tracking.py +0 -0
  77. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_empty_files_escalation.py +0 -0
  78. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_ensure_feature_branch.py +0 -0
  79. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_ensure_feature_branch_base.py +0 -0
  80. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_events_redaction.py +0 -0
  81. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_extra_gates.py +0 -0
  82. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_filelock_portable.py +0 -0
  83. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_force_full_close.py +0 -0
  84. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval.py +0 -0
  85. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval_calibration.py +0 -0
  86. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gh_backend.py +0 -0
  87. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gh_features.py +0 -0
  88. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_git_env_isolation.py +0 -0
  89. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_hashed_denylist.py +0 -0
  90. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_hashed_denylist_ci.py +0 -0
  91. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_init_integration.py +0 -0
  92. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_interpreter_normalization.py +0 -0
  93. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_leak_findings_redaction.py +0 -0
  94. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_leak_scan.py +0 -0
  95. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_leak_scan_content.py +0 -0
  96. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_learnings_query.py +0 -0
  97. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  98. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lifecycle_integration.py +0 -0
  99. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_bare_produces_path.py +0 -0
  100. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_close_intermediate.py +0 -0
  101. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_close_wu.py +0 -0
  102. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_correlation_id.py +0 -0
  103. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  104. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_oracle_env.py +0 -0
  105. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_plan_next_draft.py +0 -0
  106. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_plan_relative_import.py +0 -0
  107. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_produces_driver_helper.py +0 -0
  108. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_sections.py +0 -0
  109. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  110. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_verification_reference.py +0 -0
  111. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_attempt_evidence.py +0 -0
  112. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_auto_archive.py +0 -0
  113. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_caveman_preamble.py +0 -0
  114. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_close_intermediate.py +0 -0
  115. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_defaults_by_type.py +0 -0
  116. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_effort.py +0 -0
  117. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_failure_note_cap.py +0 -0
  118. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_gate_budget.py +0 -0
  119. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_model_alias.py +0 -0
  120. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_post_pass_invariant.py +0 -0
  121. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_reset_preserving_events.py +0 -0
  122. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_smoke_runner.py +0 -0
  123. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_unsandboxed.py +0 -0
  124. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_zero_token_guard.py +0 -0
  125. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_migrate_legacy.py +0 -0
  126. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_miniyaml_equivalence.py +0 -0
  127. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_miniyaml_negative.py +0 -0
  128. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_planned_cost_lint.py +0 -0
  129. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_prepare_feature.py +0 -0
  130. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_prepare_scaffold_sync.py +0 -0
  131. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_produces_field.py +0 -0
  132. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_redaction_windows_home.py +0 -0
  133. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_resolve_base.py +0 -0
  134. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_result_block.py +0 -0
  135. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_roadmap_add_skill.py +0 -0
  136. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_roadmap_archive_skill.py +0 -0
  137. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_roadmap_row_parser.py +0 -0
  138. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_data_in_sync.py +0 -0
  139. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_doc_hygiene.py +0 -0
  140. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_docs.py +0 -0
  141. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_init.py +0 -0
  142. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_manifest.py +0 -0
  143. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_resources.py +0 -0
  144. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_seed_sanity.py +0 -0
  145. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_upgrade.py +0 -0
  146. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_wiring.py +0 -0
  147. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_skills_vendored_in_sync.py +0 -0
  148. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_squash_commit_hook_crash.py +0 -0
  149. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_template_closing_shapes.py +0 -0
  150. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_terminal_flip_ownership.py +0 -0
  151. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_terminal_flips.py +0 -0
  152. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_timeout_kill_portable.py +0 -0
  153. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_untracked_feature_folder.py +0 -0
  154. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_untracked_file_absorption.py +0 -0
  155. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_windows_gate_exec.py +0 -0
  163. {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_wu_execution_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.18
3
+ Version: 0.3.20
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specfuse-loop"
7
- version = "0.3.18"
7
+ version = "0.3.20"
8
8
  description = "Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ 0.3.20
@@ -139,7 +139,13 @@ auto-close only eliminates the reflective overhead, not the forward-design step.
139
139
  full close ceremony regardless of predicate outcome. Alternatively, set
140
140
  `auto_close_disabled: true` in a feature's `PLAN.md` frontmatter to disable
141
141
  auto-close permanently for that feature (e.g., for features that are inherently
142
- exploratory and expect off-plan behavior).
142
+ exploratory and expect off-plan behavior). For a single **substantive close**
143
+ — a `close` (or `close-intermediate`) WU whose acceptance criteria assert on the
144
+ source tree, reconcile prior gates, or observe runtime behaviour rather than
145
+ just writing the retrospective stub — set `auto_close_disabled: true` in **that
146
+ WU's** frontmatter. The predicate keys on the implementation WUs' cost and
147
+ plan-conformance and cannot see what a close verifies, so a substantive close
148
+ must be marked must-run or its verification is silently skipped (#189).
143
149
 
144
150
  **Predicate-version transparency** — every `auto_close_decision` event in
145
151
  `events.jsonl` carries a `predicate_version` field (e.g., `predicate_version:
@@ -40,6 +40,14 @@ VALID_TYPES = {"implementation", "retrospective", "lessons", "docs", "plan-next"
40
40
  "close-intermediate"}
41
41
  VALID_STATUS = {"draft", "pending", "ready", "in_progress", "in_review", "done",
42
42
  "blocked_human", "abandoned"}
43
+ # Feature (PLAN.md) and gate status vocabularies the DRIVER branches on. Kept
44
+ # here (duplicated from loop.py's status literals, like VALID_TYPES/VALID_STATUS
45
+ # above) so the linter validates the same set the driver acts on — an
46
+ # unrecognized status that passes lint and then behaves differently at dispatch
47
+ # is the class #183 (deferred) and #185 exist to close. Keep in sync with
48
+ # find_feature / the gate-status transitions in loop.py.
49
+ VALID_FEATURE_STATUS = {"planned", "active", "deferred", "done", "abandoned"}
50
+ VALID_GATE_STATUS = {"open", "awaiting_review", "passed"}
43
51
  CLOSING_SEQUENCE = ["retrospective", "lessons", "docs", "plan-next"]
44
52
  # New compact closing shapes (FEAT-2026-0015):
45
53
  # non-terminal gate: close-intermediate → plan-next
@@ -312,6 +320,17 @@ def lint(feature_dir: Path) -> list[str]:
312
320
  if missing:
313
321
  errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
314
322
 
323
+ # Feature status must be a value the driver recognizes (#185). An
324
+ # unrecognized status passes every other check and then behaves
325
+ # differently at dispatch — the shape #183 (deferred passed lint,
326
+ # dispatched anyway) exposed. Only validate when present; the missing-key
327
+ # check above already covers absence.
328
+ if "status" in fm and fm["status"] not in VALID_FEATURE_STATUS:
329
+ errs.append(
330
+ f"PLAN.md frontmatter status {fm['status']!r} is not a recognized "
331
+ f"feature status {sorted(VALID_FEATURE_STATUS)}"
332
+ )
333
+
315
334
  # Opt-in must-reference gate signatures, loaded once for the
316
335
  # Verification-reference WARN below (#176). Empty unless the project flags
317
336
  # gates with `wu_must_reference: true`.
@@ -351,6 +370,12 @@ def lint(feature_dir: Path) -> list[str]:
351
370
  gate_path = feature_dir / gate_file_rel
352
371
  if gate_path.exists():
353
372
  gfm, _ = read_frontmatter(gate_path)
373
+ # Gate status must be a value the driver recognizes (#185).
374
+ if "status" in gfm and gfm["status"] not in VALID_GATE_STATUS:
375
+ errs.append(
376
+ f"{gate_file_rel}: gate status {gfm['status']!r} is not "
377
+ f"a recognized gate status {sorted(VALID_GATE_STATUS)}"
378
+ )
354
379
  if "cost_budget_usd" in gfm:
355
380
  val = gfm["cost_budget_usd"]
356
381
  if isinstance(val, bool) or not isinstance(val, (int, float)):
@@ -63,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
63
63
  REPO_ROOT = SPECFUSE_DIR.parent
64
64
  FEATURES_DIR = SPECFUSE_DIR / "features"
65
65
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
66
- DRIVER_VERSION = "0.3.18"
66
+ DRIVER_VERSION = "0.3.20"
67
67
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
68
68
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
69
69
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -266,11 +266,28 @@ def write_frontmatter_field(path: Path, key: str, value) -> None:
266
266
  def find_feature(arg: str | None) -> Path:
267
267
  if arg:
268
268
  d = FEATURES_DIR / arg if not arg.startswith(".") else Path(arg)
269
- if not (d / "PLAN.md").exists():
269
+ plan = d / "PLAN.md"
270
+ if not plan.exists():
270
271
  sys.exit(f"No PLAN.md under {d}")
272
+ # A `deferred` feature is parked — non-dispatchable by contract (the
273
+ # gate-status skill documents it as "non-dispatchable but resumable;
274
+ # the loop does not auto-resume it"). Refuse an explicit --feature
275
+ # targeting one instead of dispatching it anyway (#183). Malformed
276
+ # frontmatter is left to fail later where the graph is parsed.
277
+ try:
278
+ _fm, _ = read_frontmatter(plan)
279
+ except _miniyaml.MiniYAMLError:
280
+ _fm = {}
281
+ if _fm.get("status") == "deferred":
282
+ sys.exit(
283
+ f"{d.name} is deferred (parked) — the loop does not dispatch "
284
+ f"deferred features. Flip its PLAN.md status back to `active` "
285
+ f"when the blocker clears, then re-run."
286
+ )
271
287
  return d
272
288
  actives = []
273
289
  done_pending_wrap = []
290
+ deferred = []
274
291
  for d in sorted(FEATURES_DIR.glob("*/")):
275
292
  plan = d / "PLAN.md"
276
293
  if plan.exists():
@@ -293,10 +310,23 @@ def find_feature(arg: str | None) -> Path:
293
310
  # push + PR are pending.
294
311
  if (d / "RETROSPECTIVE.md").is_file():
295
312
  done_pending_wrap.append(d)
313
+ elif fm.get("status") == "deferred":
314
+ # Parked — non-dispatchable (#183). Never enters `actives`, so
315
+ # a bare invocation won't pick it up; surfaced below so a lone
316
+ # deferred feature reads as parked, not silently absent.
317
+ deferred.append(d)
296
318
  if len(actives) == 1:
297
319
  return actives[0]
298
320
  if not actives:
299
321
  msg = "No active feature. Set a feature's PLAN.md status to 'active'.\n"
322
+ if deferred:
323
+ names = ", ".join(d.name for d in deferred[-3:])
324
+ msg += (
325
+ f" - {len(deferred)} deferred (parked) feature(s), skipped: "
326
+ f"{names}\n"
327
+ f" Flip one's PLAN.md status to `active` to "
328
+ f"resume it.\n"
329
+ )
300
330
  if done_pending_wrap:
301
331
  names = ", ".join(d.name for d in done_pending_wrap[-3:])
302
332
  msg += (
@@ -1917,6 +1947,22 @@ def verify_files_changed(result: dict, head_before: str) -> list[str]:
1917
1947
  return unchanged
1918
1948
 
1919
1949
 
1950
+ def _annotate_unchanged_paths(paths: "list[str]") -> str:
1951
+ """Format files_changed-mismatch paths, distinguishing missing from unchanged.
1952
+
1953
+ Each declared-but-non-diffing path is labeled ``(missing)`` when it does
1954
+ not exist on disk or ``(no diff)`` when it exists but is byte-identical to
1955
+ head_before. Prior wording listed paths bare, which conflated the two —
1956
+ an existing, unchanged file could read as a deletion claim and seed a
1957
+ wrong root-cause diagnosis (#182). The labels say exactly what was found.
1958
+ """
1959
+ lines = []
1960
+ for p in paths:
1961
+ label = "no diff" if Path(str(p)).exists() else "missing"
1962
+ lines.append(f" - ({label}) {p}")
1963
+ return "\n".join(lines)
1964
+
1965
+
1920
1966
  # Smoke-import runner (FEAT-2026-0008/T03). The conservative pattern matches
1921
1967
  # ONLY a `python3 -c "from X import Y"` line. The agent-authored WU body may
1922
1968
  # declare an existence check naming new symbols this WU just minted; the
@@ -2732,6 +2778,56 @@ def _already_auto_closed(wu_file: Path) -> bool:
2732
2778
  return auto in (True, "true", "True")
2733
2779
 
2734
2780
 
2781
+ def _gate_impl_deliverables_present(
2782
+ feature_dir: Path, gate: "GateNode",
2783
+ ) -> tuple[bool, str]:
2784
+ """Return (True, "") iff every implementation WU in *gate* has its declared
2785
+ ``produces:`` deliverables present and non-empty on disk (#190).
2786
+
2787
+ Auto-close keys on WU cost/plan-conformance and trusts each WU's ``done``
2788
+ status; ``assert_declared_deliverables`` — the dispatch-time presence gate —
2789
+ never runs on an auto-closed gate, so a WU that reported ``done`` with its
2790
+ ``produces:`` paths absent still counts as a clean gate. Running the same
2791
+ check here stops the predicate auto-closing over a ``done`` that did not
2792
+ deliver. Non-implementation WUs and WUs with no ``produces:`` are skipped
2793
+ (the latter is assert_declared_deliverables's own opt-out). A missing or
2794
+ malformed WU file is left to the predicate / dispatch path that already
2795
+ handles it, not crashed on here.
2796
+ """
2797
+ for ref in gate.refs:
2798
+ try:
2799
+ wu = load_wu(feature_dir, ref)
2800
+ except (FileNotFoundError, KeyError, _miniyaml.MiniYAMLError):
2801
+ continue
2802
+ if wu.type != "implementation":
2803
+ continue
2804
+ ok, summary = assert_declared_deliverables(wu)
2805
+ if not ok:
2806
+ return False, f"{ref.get('id', wu.wu_id)}: {summary}"
2807
+ return True, ""
2808
+
2809
+
2810
+ def _close_wu_disables_auto_close(close_wu: "WorkUnit | None") -> bool:
2811
+ """True iff the close WU's frontmatter sets ``auto_close_disabled`` truthy.
2812
+
2813
+ Per-WU counterpart to PLAN.md's feature-level ``auto_close_disabled``
2814
+ (#189). The auto-close predicate keys on the implementation WUs' cost and
2815
+ plan-conformance; it has no signal for what the CLOSE WU itself is
2816
+ contracted to verify. A substantive close — one whose acceptance criteria
2817
+ assert on the source tree, reconcile prior gates, or observe runtime
2818
+ behaviour — must be dispatched and run, not optimized away. The plan author
2819
+ marks such a close with ``auto_close_disabled: true`` in its frontmatter;
2820
+ the predicate then always defers to dispatch for that gate.
2821
+
2822
+ Distinct from the ``auto_close`` key, which is the auto-close OUTPUT marker
2823
+ (set to ``true`` by mark_close_wu_auto_closed after a gate auto-closes).
2824
+ """
2825
+ if close_wu is None or not close_wu.file.is_file():
2826
+ return False
2827
+ fm, _ = read_frontmatter(close_wu.file)
2828
+ return fm.get("auto_close_disabled") in (True, "true", "True")
2829
+
2830
+
2735
2831
  def write_stub_retrospective_terminal(
2736
2832
  feature_dir: Path,
2737
2833
  gate_number: int,
@@ -2835,9 +2931,32 @@ def maybe_auto_close_terminal(
2835
2931
  feature_id=feature_id,
2836
2932
  predicate_version="v1",
2837
2933
  )
2934
+ if _close_wu_disables_auto_close(close_wu_for_terminal):
2935
+ # Substantive close marked must-run (#189): dispatch it, don't skip.
2936
+ return False, AutoCloseDecision(
2937
+ auto=False,
2938
+ reasons=["auto_close_disabled_per_wu"],
2939
+ metrics={},
2940
+ gate_id=gate.number,
2941
+ feature_id=feature_id,
2942
+ predicate_version="v1",
2943
+ )
2838
2944
  decision = evaluate_auto_close(feature_dir, gate.number)
2839
2945
  if not decision.auto:
2840
2946
  return False, decision
2947
+ present, reason = _gate_impl_deliverables_present(feature_dir, gate)
2948
+ if not present:
2949
+ # A gate WU reported done without its declared deliverable on disk;
2950
+ # do not auto-close over the discrepancy — dispatch the close, which is
2951
+ # where it surfaces (#190).
2952
+ return False, AutoCloseDecision(
2953
+ auto=False,
2954
+ reasons=[f"declared_deliverable_missing: {reason}"],
2955
+ metrics=decision.metrics,
2956
+ gate_id=gate.number,
2957
+ feature_id=feature_id,
2958
+ predicate_version=decision.predicate_version,
2959
+ )
2841
2960
  write_stub_retrospective_terminal(feature_dir, gate.number, decision)
2842
2961
  mark_close_wu_auto_closed(close_wu_for_terminal, decision)
2843
2962
  metrics = decision.metrics
@@ -2948,9 +3067,31 @@ def maybe_auto_close_intermediate(
2948
3067
  feature_id=feature_id,
2949
3068
  predicate_version="v1",
2950
3069
  )
3070
+ if _close_wu_disables_auto_close(close_intermediate_wu):
3071
+ # Substantive close-intermediate marked must-run (#189): dispatch it.
3072
+ return False, AutoCloseDecision(
3073
+ auto=False,
3074
+ reasons=["auto_close_disabled_per_wu"],
3075
+ metrics={},
3076
+ gate_id=gate.number,
3077
+ feature_id=feature_id,
3078
+ predicate_version="v1",
3079
+ )
2951
3080
  decision = evaluate_auto_close(feature_dir, gate.number)
2952
3081
  if not decision.auto:
2953
3082
  return False, decision
3083
+ present, reason = _gate_impl_deliverables_present(feature_dir, gate)
3084
+ if not present:
3085
+ # See maybe_auto_close_terminal: don't auto-close over a done WU whose
3086
+ # declared deliverable is absent — dispatch the close instead (#190).
3087
+ return False, AutoCloseDecision(
3088
+ auto=False,
3089
+ reasons=[f"declared_deliverable_missing: {reason}"],
3090
+ metrics=decision.metrics,
3091
+ gate_id=gate.number,
3092
+ feature_id=feature_id,
3093
+ predicate_version=decision.predicate_version,
3094
+ )
2954
3095
  append_stub_retrospective_intermediate(feature_dir, gate.number, decision)
2955
3096
  if close_intermediate_wu is not None:
2956
3097
  write_frontmatter_field(close_intermediate_wu.file, "status", "done")
@@ -4274,17 +4415,33 @@ def run(
4274
4415
  # against head_before. Treat as a failed attempt: skip
4275
4416
  # squash, reset the tree, record evidence, retry within
4276
4417
  # budget. payload is the list of unchanged paths.
4418
+ _mm_paths = list(payload)
4277
4419
  note = (
4278
- "RESULT block declared `files_changed` paths that "
4279
- "show NO diff against HEAD before this attempt:\n"
4280
- + "\n".join(f" - {p}" for p in payload)
4281
- + "\nEither actually modify them, or correct the "
4282
- "files_changed list to match what you really edited."
4420
+ "The RESULT block declared these `files_changed` "
4421
+ "paths, but each shows NO diff against HEAD before "
4422
+ "this attempt:\n"
4423
+ + _annotate_unchanged_paths(_mm_paths)
4424
+ + "\n\nDeclaring an unchanged path is what fails this "
4425
+ "guard. For each path: if it did NOT need changing, "
4426
+ "OMIT it from `files_changed` (declare only files you "
4427
+ "actually edit); if it SHOULD have changed, make the "
4428
+ "edit this attempt. A `(missing)` path was never "
4429
+ "created; a `(no diff)` path exists but is unchanged."
4283
4430
  )
4431
+ # Populate failure_class/signature/excerpt (#182): a
4432
+ # driver-side rejection previously emitted all-null, so
4433
+ # events.jsonl — and gate-status, which reads only it —
4434
+ # showed empty columns exactly where the diagnosis lives.
4435
+ _mm_sig = ", ".join(
4436
+ sorted({Path(str(p)).name for p in _mm_paths})
4437
+ )[:100] or "unchanged"
4284
4438
  wu_events.append(emit_attempt_outcome(
4285
4439
  wu, attempt, "files_changed_mismatch",
4286
4440
  attempts_usage[-1],
4287
- extras={"unchanged_paths": list(payload)},
4441
+ failure_class="files_changed_mismatch",
4442
+ failure_signature=_mm_sig,
4443
+ failure_excerpt=extract_failure_excerpt(note),
4444
+ extras={"unchanged_paths": _mm_paths},
4288
4445
  ))
4289
4446
  attempt_notes.append((attempt, note))
4290
4447
  failure_note = note
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.18
3
+ Version: 0.3.20
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -245,6 +245,17 @@ class TestAutoCloseIntermediateFiresOnCleanGate(unittest.TestCase):
245
245
  self.events_path, self.root, self.ci_wu, self.plan_next_wu,
246
246
  )
247
247
 
248
+ def test_per_wu_opt_out_refuses(self):
249
+ # #189: close-intermediate marked auto_close_disabled → not auto-closed.
250
+ self.ci_wu.file.write_text(
251
+ f"---\nid: {self.feature_id}/G1-CI\ntype: close-intermediate\n"
252
+ f"model: opus\nstatus: pending\nattempts: 0\n"
253
+ f"auto_close_disabled: true\n---\n\n# CI\n"
254
+ )
255
+ result, decision = self._call()
256
+ self.assertFalse(result)
257
+ self.assertIn("auto_close_disabled_per_wu", decision.reasons)
258
+
248
259
  def test_returns_true_and_decision(self):
249
260
  result, decision = self._call()
250
261
  self.assertTrue(result)
@@ -256,6 +256,117 @@ class TestMaybeAutoCloseTerminalAutoPath(unittest.TestCase):
256
256
  # ---------------------------------------------------------------------------
257
257
 
258
258
 
259
+ class TestMaybeAutoCloseTerminalPerWuOptOut(unittest.TestCase):
260
+ """#189: a close WU marked auto_close_disabled must NOT be auto-closed,
261
+ even on an otherwise clean/on-plan gate — it is dispatched instead."""
262
+
263
+ def setUp(self):
264
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
265
+ self.root = Path(self._tmp.name)
266
+ self.feature_id = "FEAT-2026-9981"
267
+ self.gate_num = 1
268
+ fdir = self.root
269
+ self.fdir = fdir
270
+ _write_plan_md(fdir, self.feature_id, self.gate_num)
271
+ _write_wu_impl(fdir, f"{self.feature_id}/T01")
272
+ # Close WU carries the per-WU opt-out.
273
+ (fdir / "WU-close.md").write_text(
274
+ f"---\nid: {self.feature_id}/G{self.gate_num}-CLOSE\ntype: close\n"
275
+ f"model: opus\nstatus: pending\nattempts: 0\n"
276
+ f"auto_close_disabled: true\n---\n\n# Close\n"
277
+ )
278
+ _write_gate_file(fdir, self.gate_num)
279
+ _write_task_completed_event(fdir, f"{self.feature_id}/T01")
280
+ self.gate = _make_gate_node(fdir, self.feature_id, self.gate_num)
281
+ self.close_wu = _make_close_wu(
282
+ fdir, f"{self.feature_id}/G{self.gate_num}-CLOSE")
283
+ self.events_path = fdir / "events.jsonl"
284
+
285
+ def tearDown(self):
286
+ self._tmp.cleanup()
287
+
288
+ def test_refuses_and_names_reason(self):
289
+ result, decision = loop.maybe_auto_close_terminal(
290
+ self.fdir, self.feature_id, self.gate, [self.gate],
291
+ self.events_path, self.close_wu, repo_root=self.root,
292
+ )
293
+ self.assertFalse(result)
294
+ self.assertFalse(decision.auto)
295
+ self.assertIn("auto_close_disabled_per_wu", decision.reasons)
296
+
297
+ def test_close_wu_not_marked_auto_closed(self):
298
+ loop.maybe_auto_close_terminal(
299
+ self.fdir, self.feature_id, self.gate, [self.gate],
300
+ self.events_path, self.close_wu, repo_root=self.root,
301
+ )
302
+ fm, _ = loop.read_frontmatter(self.close_wu.file)
303
+ # Status untouched (still dispatchable); no auto_close output marker.
304
+ self.assertEqual(fm.get("status"), "pending")
305
+ self.assertNotEqual(fm.get("auto_close"), True)
306
+
307
+ def test_helper_reads_the_flag(self):
308
+ self.assertTrue(loop._close_wu_disables_auto_close(self.close_wu))
309
+
310
+
311
+ class TestMaybeAutoCloseTerminalDeliverableMissing(unittest.TestCase):
312
+ """#190: auto-close must refuse when an implementation WU reports done but
313
+ its declared `produces:` deliverable is absent — dispatch the close."""
314
+
315
+ def setUp(self):
316
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
317
+ self.root = Path(self._tmp.name)
318
+ self.feature_id = "FEAT-2026-9982"
319
+ self.gate_num = 1
320
+ fdir = self.root
321
+ self.fdir = fdir
322
+ _write_plan_md(fdir, self.feature_id, self.gate_num)
323
+ # Implementation WU claims done + a produces path that does NOT exist.
324
+ missing = fdir / "src" / "never-created.py"
325
+ (fdir / "WU-01.md").write_text(
326
+ f"---\nid: {self.feature_id}/T01\ntype: implementation\n"
327
+ f"model: sonnet\nstatus: done\nattempts: 1\ncost_usd: 0.5\n"
328
+ f"planned_cost_usd: 0.5\nproduces: [{missing}]\n---\n\n# T01\n"
329
+ )
330
+ _write_wu_close(fdir, f"{self.feature_id}/G{self.gate_num}-CLOSE")
331
+ _write_gate_file(fdir, self.gate_num)
332
+ _write_task_completed_event(fdir, f"{self.feature_id}/T01")
333
+ self.gate = _make_gate_node(fdir, self.feature_id, self.gate_num)
334
+ self.close_wu = _make_close_wu(
335
+ fdir, f"{self.feature_id}/G{self.gate_num}-CLOSE")
336
+ self.events_path = fdir / "events.jsonl"
337
+
338
+ def tearDown(self):
339
+ self._tmp.cleanup()
340
+
341
+ def _call(self):
342
+ return loop.maybe_auto_close_terminal(
343
+ self.fdir, self.feature_id, self.gate, [self.gate],
344
+ self.events_path, self.close_wu, repo_root=self.root,
345
+ )
346
+
347
+ def test_refuses_on_missing_deliverable(self):
348
+ result, decision = self._call()
349
+ self.assertFalse(result)
350
+ self.assertFalse(decision.auto)
351
+ self.assertTrue(any("declared_deliverable_missing" in r
352
+ for r in decision.reasons),
353
+ f"reasons={decision.reasons}")
354
+
355
+ def test_present_deliverable_still_auto_closes(self):
356
+ # Create the declared deliverable → predicate proceeds to auto-close.
357
+ missing = self.fdir / "src" / "never-created.py"
358
+ missing.parent.mkdir(parents=True, exist_ok=True)
359
+ missing.write_text("x = 1\n")
360
+ result, decision = self._call()
361
+ self.assertTrue(result)
362
+ self.assertTrue(decision.auto)
363
+
364
+ def test_helper_reports_the_offender(self):
365
+ ok, reason = loop._gate_impl_deliverables_present(self.fdir, self.gate)
366
+ self.assertFalse(ok)
367
+ self.assertIn("never-created.py", reason)
368
+
369
+
259
370
  class TestMaybeAutoCloseTerminalNonAutoPath(unittest.TestCase):
260
371
 
261
372
  def setUp(self):
@@ -104,6 +104,46 @@ class TestLintErrorArms(unittest.TestCase):
104
104
  self.assertTrue(any("frontmatter missing keys" in e for e in errs),
105
105
  f"expected missing-keys error; errs={errs}")
106
106
 
107
+ def test_unknown_feature_status_errors(self):
108
+ """#185: a feature status outside the driver's enum is a lint error."""
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ feat = _build_minimal_feature(Path(tmp))
111
+ plan = feat / "PLAN.md"
112
+ plan.write_text(plan.read_text().replace(
113
+ "status: active", "status: paused", 1))
114
+ errs = lint_plan.lint(feat)
115
+ self.assertTrue(
116
+ any("'paused'" in e and "feature status" in e for e in errs),
117
+ f"expected feature-status error; errs={errs}")
118
+
119
+ def test_known_feature_statuses_do_not_error(self):
120
+ """Every documented feature status passes the vocabulary check."""
121
+ for status in ("planned", "active", "deferred", "done", "abandoned"):
122
+ with tempfile.TemporaryDirectory() as tmp:
123
+ feat = _build_minimal_feature(Path(tmp))
124
+ plan = feat / "PLAN.md"
125
+ plan.write_text(plan.read_text().replace(
126
+ "status: active", f"status: {status}", 1))
127
+ errs = lint_plan.lint(feat)
128
+ self.assertFalse(
129
+ any("feature status" in e for e in errs),
130
+ f"{status!r} should be a valid feature status; errs={errs}")
131
+
132
+ def test_unknown_gate_status_errors(self):
133
+ """#185: a gate status outside the driver's enum is a lint error."""
134
+ with tempfile.TemporaryDirectory() as tmp:
135
+ feat = _build_minimal_feature(Path(tmp))
136
+ (feat / "GATE-01.md").write_text(
137
+ "---\nstatus: reviewing\n---\n\n# Gate 1\n")
138
+ # Point the graph's gate at the file so lint reads it.
139
+ plan = feat / "PLAN.md"
140
+ plan.write_text(plan.read_text().replace(
141
+ " - gate: 1\n", " - gate: 1\n file: GATE-01.md\n", 1))
142
+ errs = lint_plan.lint(feat)
143
+ self.assertTrue(
144
+ any("'reviewing'" in e and "gate status" in e for e in errs),
145
+ f"expected gate-status error; errs={errs}")
146
+
107
147
  def test_plan_no_yaml_block(self):
108
148
  """Line 91: PLAN.md has valid frontmatter but no fenced yaml graph block."""
109
149
  with tempfile.TemporaryDirectory() as tmp:
@@ -133,6 +133,15 @@ class TestVerifyFilesChanged(unittest.TestCase):
133
133
  self.assertEqual(
134
134
  loop.verify_files_changed(result, self.head), ["ignored.log"])
135
135
 
136
+ def test_annotate_distinguishes_missing_from_no_diff(self):
137
+ # #182: never label an existing-but-unchanged file as deleted. An
138
+ # on-disk path → "(no diff)"; an absent path → "(missing)".
139
+ (self.root / "present.py").write_text("x\n")
140
+ out = loop._annotate_unchanged_paths(["present.py", "gone.py"])
141
+ self.assertIn("(no diff) present.py", out)
142
+ self.assertIn("(missing) gone.py", out)
143
+ self.assertNotIn("deleted", out)
144
+
136
145
 
137
146
  def write_minimal_feature(root: Path, feature_id: str, slug: str,
138
147
  branch: str, wus: list) -> Path:
@@ -302,6 +311,13 @@ class TestFilesChangedMismatchSpinsIntegration(unittest.TestCase):
302
311
  self.assertEqual(ev["payload"]["unchanged_paths"],
303
312
  ["sentinel.py"],
304
313
  "payload must name the sentinel path")
314
+ # #182: driver-side rejections must populate the diagnosis
315
+ # fields, not emit all-null — gate-status reads only events.jsonl.
316
+ self.assertEqual(ev["payload"]["failure_class"],
317
+ "files_changed_mismatch")
318
+ self.assertIn("sentinel.py", ev["payload"]["failure_signature"])
319
+ self.assertTrue(ev["payload"]["failure_excerpt"],
320
+ "failure_excerpt must not be null/empty")
305
321
 
306
322
  types = [e["event_type"] for e in events]
307
323
  self.assertNotIn("task_completed", types,
@@ -343,6 +343,31 @@ class TestFindFeatureSelection(unittest.TestCase):
343
343
  # The bad one is skipped; the good active feature still resolves.
344
344
  self.assertEqual(loop.find_feature(None), good)
345
345
 
346
+ def test_explicit_deferred_feature_is_refused(self):
347
+ """#183: `--feature` on a deferred feature must refuse, not dispatch."""
348
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
349
+ with self.assertRaises(SystemExit) as ctx:
350
+ loop.find_feature("FEAT-2026-9502-parked")
351
+ msg = str(ctx.exception.code).lower()
352
+ self.assertIn("deferred", msg)
353
+ self.assertIn("active", msg) # points at the fix
354
+
355
+ def test_deferred_not_treated_as_active_in_scan(self):
356
+ """#183: a lone deferred feature yields 'no active', named as parked."""
357
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
358
+ with self.assertRaises(SystemExit) as ctx:
359
+ loop.find_feature(None)
360
+ msg = str(ctx.exception.code)
361
+ self.assertIn("No active feature", msg)
362
+ self.assertIn("parked", msg.lower())
363
+ self.assertIn("FEAT-2026-9502-parked", msg)
364
+
365
+ def test_deferred_does_not_block_a_real_active(self):
366
+ """A deferred feature alongside one active resolves to the active."""
367
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
368
+ good = self._write_plan("FEAT-2026-9502-live", "active")
369
+ self.assertEqual(loop.find_feature(None), good)
370
+
346
371
  def test_read_frontmatter_error_names_the_file(self):
347
372
  """#177 fix 1: a parse error carries the file path, not just a
348
373
  block-relative line number."""
@@ -370,11 +395,13 @@ class TestFindFeatureSelection(unittest.TestCase):
370
395
  self._write_plan("FEAT-2026-9502-parked", "deferred")
371
396
  self.assertEqual(loop.find_feature(None), active_dir)
372
397
 
373
- def test_deferred_still_selectable_by_explicit_arg(self):
374
- """A human can still target a `deferred` feature explicitly (e.g. to
375
- inspect it); only auto-selection skips it."""
376
- fdir = self._write_plan("FEAT-2026-9502-parked", "deferred")
377
- self.assertEqual(loop.find_feature("FEAT-2026-9502-parked"), fdir)
398
+ # NOTE (#183): the prior test asserted a `deferred` feature was still
399
+ # dispatchable via an explicit --feature (only auto-selection skipped it,
400
+ # from #106). That contradicted the shipped contract — the PLAN template,
401
+ # methodology.md, and the gate-status skill all document `deferred` as
402
+ # "non-dispatchable". #183 resolves the contradiction in favor of the docs:
403
+ # explicit dispatch is now refused (see test_explicit_deferred_feature_is_
404
+ # refused above), so that assertion is intentionally removed.
378
405
 
379
406
  def test_explicit_arg_without_plan_exits(self):
380
407
  """Lines 188-190: explicit dir name with no PLAN.md → sys.exit."""
@@ -1 +0,0 @@
1
- 0.3.18
File without changes
File without changes
File without changes
File without changes