specfuse-loop 0.3.22__tar.gz → 0.3.23__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 (170) hide show
  1. {specfuse_loop-0.3.22/specfuse_loop.egg-info → specfuse_loop-0.3.23}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.23/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/methodology.md +12 -0
  5. specfuse_loop-0.3.23/specfuse/loop/data/rules/close-discipline.md +74 -0
  6. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/templates/WU.template.md +20 -0
  7. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/lint_plan.py +52 -0
  8. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/loop.py +12 -9
  9. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/scaffold.py +75 -5
  10. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23/specfuse_loop.egg-info}/PKG-INFO +1 -1
  11. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/SOURCES.txt +2 -0
  12. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_init_integration.py +2 -0
  13. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_close_intermediate.py +8 -0
  14. specfuse_loop-0.3.23/tests/test_lint_planning_sections.py +117 -0
  15. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_gate_budget.py +28 -4
  16. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_data_in_sync.py +1 -0
  17. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_docs.py +7 -0
  18. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_init.py +2 -0
  19. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_resources.py +1 -0
  20. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_upgrade.py +55 -1
  21. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_upgrade_integration.py +8 -0
  22. specfuse_loop-0.3.22/specfuse/loop/data/VERSION +0 -1
  23. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/LICENSE +0 -0
  24. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/NOTICE +0 -0
  25. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/README.md +0 -0
  26. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/setup.cfg +0 -0
  27. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/__init__.py +0 -0
  28. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/_filelock.py +0 -0
  29. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/_miniyaml.py +0 -0
  30. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/adopt_feature.py +0 -0
  31. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  32. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  33. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  34. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/getting-started.md +0 -0
  35. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/skills.md +0 -0
  36. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/gitignore.snippet +0 -0
  37. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/roadmap.template.md +0 -0
  38. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  39. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/never-touch.md +0 -0
  40. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/planning-discipline.md +0 -0
  41. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/result-contract.md +0 -0
  42. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  43. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  44. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules-local/README.md +0 -0
  45. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/event.schema.json +0 -0
  46. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  47. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  48. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  49. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  50. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/templates/GATE.template.md +0 -0
  51. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  52. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/verification.yml.example +0 -0
  53. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/gate_eval.py +0 -0
  54. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/gh_backend.py +0 -0
  55. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/gh_features.py +0 -0
  56. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/validate_event.py +0 -0
  57. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  58. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/entry_points.txt +0 -0
  59. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/requires.txt +0 -0
  60. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/top_level.txt +0 -0
  61. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_adopt_feature.py +0 -0
  62. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  63. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_attempt_outcome_emission.py +0 -0
  64. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autoclose_deferral_visibility.py +0 -0
  65. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync.py +0 -0
  66. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync_consent.py +0 -0
  67. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync_firstrun.py +0 -0
  68. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync_plugin.py +0 -0
  69. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_backend.py +0 -0
  70. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bash_routing.py +0 -0
  71. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  72. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  73. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bookkeeping_no_verify.py +0 -0
  74. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_claude_resolution.py +0 -0
  75. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_close_wu_self_reference.py +0 -0
  76. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_closing_deliverable_guard.py +0 -0
  77. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_console_encoding.py +0 -0
  78. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_cost_tracking.py +0 -0
  79. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_degraded_oracle.py +0 -0
  80. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_deliverable_presence_gate.py +0 -0
  81. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_doctor.py +0 -0
  82. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_driver_integration.py +0 -0
  83. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_driver_lock.py +0 -0
  84. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_duration_tracking.py +0 -0
  85. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_empty_files_escalation.py +0 -0
  86. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_ensure_feature_branch.py +0 -0
  87. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_ensure_feature_branch_base.py +0 -0
  88. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_events_redaction.py +0 -0
  89. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_extra_gates.py +0 -0
  90. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_filelock_portable.py +0 -0
  91. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_force_full_close.py +0 -0
  92. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval.py +0 -0
  93. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval_calibration.py +0 -0
  94. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  95. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval_terminal_wiring.py +0 -0
  96. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gh_backend.py +0 -0
  97. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gh_features.py +0 -0
  98. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_git_env_isolation.py +0 -0
  99. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_hashed_denylist.py +0 -0
  100. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_hashed_denylist_ci.py +0 -0
  101. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_interpreter_normalization.py +0 -0
  102. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_leak_findings_redaction.py +0 -0
  103. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_leak_scan.py +0 -0
  104. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_leak_scan_content.py +0 -0
  105. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_learnings_query.py +0 -0
  106. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  107. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lifecycle_integration.py +0 -0
  108. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lifetime_accounting.py +0 -0
  109. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_bare_produces_path.py +0 -0
  110. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_close_wu.py +0 -0
  111. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_correlation_id.py +0 -0
  112. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  113. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_oracle_env.py +0 -0
  114. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_plan_errors.py +0 -0
  115. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_plan_next_draft.py +0 -0
  116. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_plan_relative_import.py +0 -0
  117. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_produces_driver_helper.py +0 -0
  118. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_sections.py +0 -0
  119. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  120. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_verification_reference.py +0 -0
  121. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_attempt_evidence.py +0 -0
  122. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_auto_archive.py +0 -0
  123. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_caveman_preamble.py +0 -0
  124. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_close_intermediate.py +0 -0
  125. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_defaults_by_type.py +0 -0
  126. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_effort.py +0 -0
  127. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_failure_note_cap.py +0 -0
  128. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_files_changed_guard.py +0 -0
  129. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_model_alias.py +0 -0
  130. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_orchestration.py +0 -0
  131. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_post_pass_invariant.py +0 -0
  132. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_reset_preserving_events.py +0 -0
  133. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_smoke_runner.py +0 -0
  134. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_unsandboxed.py +0 -0
  135. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_zero_token_guard.py +0 -0
  136. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_migrate_legacy.py +0 -0
  137. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_miniyaml_equivalence.py +0 -0
  138. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_miniyaml_negative.py +0 -0
  139. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_planned_cost_lint.py +0 -0
  140. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_prepare_feature.py +0 -0
  141. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_prepare_scaffold_sync.py +0 -0
  142. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_produces_field.py +0 -0
  143. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_produces_in_diff.py +0 -0
  144. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_redaction_windows_home.py +0 -0
  145. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_resolve_base.py +0 -0
  146. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_result_block.py +0 -0
  147. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_roadmap_add_skill.py +0 -0
  148. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_roadmap_archive_skill.py +0 -0
  149. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_roadmap_row_parser.py +0 -0
  150. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_doc_hygiene.py +0 -0
  151. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_manifest.py +0 -0
  152. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_seed_sanity.py +0 -0
  153. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_wiring.py +0 -0
  154. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_skills_vendored_in_sync.py +0 -0
  155. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_spinning_rearm_gate.py +0 -0
  156. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_squash_commit_hook_crash.py +0 -0
  157. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_template_closing_shapes.py +0 -0
  158. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_terminal_flip_ownership.py +0 -0
  159. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_terminal_flips.py +0 -0
  160. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_timeout_kill_portable.py +0 -0
  161. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_untracked_feature_folder.py +0 -0
  162. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_untracked_file_absorption.py +0 -0
  163. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_upgrade_merge_gate.py +0 -0
  164. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_validate_event.py +0 -0
  165. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_verdict_coupling.py +0 -0
  166. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_verify_empty_gate_set.py +0 -0
  167. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_version_consistency.py +0 -0
  168. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_version_skew.py +0 -0
  169. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_windows_gate_exec.py +0 -0
  170. {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/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.22
3
+ Version: 0.3.23
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.22"
7
+ version = "0.3.23"
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.23
@@ -147,6 +147,18 @@ WU's** frontmatter. The predicate keys on the implementation WUs' cost and
147
147
  plan-conformance and cannot see what a close verifies, so a substantive close
148
148
  must be marked must-run or its verification is silently skipped (#189).
149
149
 
150
+ **Hedged verdicts leave a trail** — a close that ends `met_locally` (everything
151
+ locally verifiable passed; something structurally could not be verified here)
152
+ leaves every terminal surface un-flipped (driver >= 0.3.21: gate
153
+ `awaiting_review`, roadmap row `active`, PLAN.md `active`) AND must produce a
154
+ follow-up record — in the gate review or `RETROSPECTIVE.md` — naming each
155
+ unmet criterion, why it was unverifiable in this environment, and the exact
156
+ re-run condition that would upgrade the verdict to `met`. Without the record,
157
+ `met_locally` is a dead end; with it, the feature is resumable by anyone. See
158
+ `.specfuse/rules/close-discipline.md` §2 for the record's shape and the other
159
+ close-time obligations (fresh oracle re-runs, consumer-visible-change
160
+ enumeration).
161
+
150
162
  **Predicate-version transparency** — every `auto_close_decision` event in
151
163
  `events.jsonl` carries a `predicate_version` field (e.g., `predicate_version:
152
164
  v1`). Future revisions to the predicate constants increment this version, so
@@ -0,0 +1,74 @@
1
+ <!--
2
+ Copyright 2026 Specfuse Contributors
3
+ Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ -->
5
+
6
+ # Rule: close discipline
7
+
8
+ Three obligations for `close` and `close-intermediate` WUs, binding at close
9
+ time. Sibling of [`planning-discipline.md`](planning-discipline.md) (plan/arm
10
+ time) and of the per-WU closing obligations in
11
+ [`result-contract.md`](result-contract.md). Provenance: FEAT-2026-0049
12
+ (the specfuse-generator dogfood), where three gates auto-closed at
13
+ `attempts: 0` and the one close forced to run caught two false-`done` WUs and
14
+ an unsatisfiable predicate — 48% of the feature's spend landed after the
15
+ terminal gate had already "auto-closed". A close that only writes prose
16
+ verifies nothing.
17
+
18
+ A close whose acceptance criteria include ANY obligation below is
19
+ **load-bearing**: the plan author sets `auto_close_disabled: true` in that
20
+ WU's frontmatter so the auto-close predicate cannot optimize it away (#189).
21
+
22
+ ## 1. Oracles re-run fresh
23
+
24
+ The close re-runs every oracle the feature's acceptance criteria name — the
25
+ full test command(s), plus any compile/execution gates — fresh, in this
26
+ session, exit codes read directly. Never inherit a producing WU's
27
+ self-report: `done` is a claim, the re-run is the verification. When the
28
+ feature's criteria assert on generated artifacts, regenerate into a clean
29
+ output directory before asserting — stale output satisfies any assertion.
30
+
31
+ > **Provenance.** A WU reported `done` while its source was untouched and its
32
+ > oracle never ran; the driver-side produces-vs-diff guard now refuses that
33
+ > pass (specfuse-loop >= 0.3.21), but only the close's own fresh re-run
34
+ > catches the composite: all WUs individually green while the feature-level
35
+ > oracle fails.
36
+
37
+ ## 2. Hedged-verdict follow-up record
38
+
39
+ On `met_locally`, the close must produce a named record — in the gate review
40
+ or `RETROSPECTIVE.md` — with one entry per unmet criterion:
41
+
42
+ - the criterion, verbatim;
43
+ - why it is unverifiable in this environment;
44
+ - the exact re-run condition that would upgrade the verdict to `met`.
45
+
46
+ The driver (>= 0.3.21) already guarantees the surfaces stay un-flipped on a
47
+ hedged verdict (gate `awaiting_review`, roadmap `active`, PLAN `active`);
48
+ this record is the other half: without it, `met_locally` is a dead end —
49
+ no artifact says what would make it `met`, and the honest hedged verdict
50
+ degrades into a polite synonym for "unknown".
51
+
52
+ ## 3. Consumer-visible contract changes enumerated, human-acknowledged
53
+
54
+ The close enumerates every consumer-visible addition, removal, or rename the
55
+ feature makes across ALL its producing WUs — API surface, generated models,
56
+ published schemas, CLI flags, whatever contract consumers depend on — and
57
+ blocks on explicit human acknowledgment of the list. A feature with no such
58
+ changes writes exactly: `n/a — no consumer-visible contract change` (do not
59
+ fabricate an empty enumeration; the n/a line is the reviewed claim).
60
+
61
+ A close carrying this obligation is always load-bearing
62
+ (`auto_close_disabled: true`).
63
+
64
+ > **Provenance.** A breaking removal from a generated model survived four
65
+ > gates unreviewed because no close surface owned the breaking-change list.
66
+ > A silent breaking change is the most expensive false-done, and it is not
67
+ > language-specific — any generated or published contract has this surface.
68
+
69
+ ## Split with project-local rules
70
+
71
+ These are the generic obligations. The concrete grounding — which command is
72
+ the oracle, which surface is "the API", project-specific regeneration
73
+ hazards — is per-project and belongs in that project's
74
+ `.specfuse/rules-local/` (never touched by upgrade), referencing this rule.
@@ -92,6 +92,26 @@ defect gates later.
92
92
  |---|---|---|
93
93
  | `<path/method>` | yes / no | <one line> |
94
94
 
95
+ **Close obligations** (REQUIRED only for `close` / `close-intermediate` WUs; omit
96
+ otherwise — see `.specfuse/rules/close-discipline.md`). The close's acceptance
97
+ criteria must cover:
98
+
99
+ 1. **Oracles re-run fresh** (§1): every oracle the feature's criteria name,
100
+ full command(s), exit codes read directly — never a producing WU's
101
+ self-report; regenerate into a clean dir before asserting on generated
102
+ artifacts.
103
+ 2. **Hedged follow-up record** (§2): on `met_locally`, a named record per
104
+ unmet criterion — criterion, why unverifiable here, exact re-run condition
105
+ that upgrades it to `met`.
106
+ 3. **Consumer-visible contract changes** (§3): enumerate every addition /
107
+ removal / rename across the feature's producing WUs and block on human
108
+ acknowledgment, or write exactly `n/a — no consumer-visible contract
109
+ change`.
110
+
111
+ A close whose criteria include any of these is load-bearing — set
112
+ `auto_close_disabled: true` in this WU's frontmatter so the auto-close
113
+ predicate cannot skip it.
114
+
95
115
  **Do not touch.** Generated directories (`_generated/`, `gen-src/`, or the repo's
96
116
  declared equivalent), files owned by other work units in this gate, secrets,
97
117
  `.git/`. The driver owns all git operations — you edit files only. See
@@ -335,6 +335,56 @@ def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
335
335
  )
336
336
 
337
337
 
338
+ # Planning-discipline section names (#201, second half). Canonical since the
339
+ # FEAT-2026-0049 planning-discipline drop (PR #211): PLAN.md carries the two
340
+ # ADR sections; each gate file carries the arming-discipline section. Names
341
+ # are shared vocabulary with downstream planning tooling — do not rename here
342
+ # without a template/coordination change.
343
+ PLAN_DISCIPLINE_SECTIONS = (
344
+ "Existing-mechanism search",
345
+ "Escalation-predicate satisfiability",
346
+ )
347
+ GATE_ARMING_SECTION = "Arming discipline"
348
+
349
+
350
+ def check_planning_sections(
351
+ feature_dir: Path, plan_fm: dict, plan_body: str, gates: list,
352
+ ) -> None:
353
+ """WARN when the planning-discipline sections are absent (#201).
354
+
355
+ Presence-only: the PLAN template's sections allow an explicit n/a line, so
356
+ content is the arming reviewer's job — this check only catches the section
357
+ never being written at all (pre-0.3.22 drafts, or a plan-next that dropped
358
+ it). Sealed features (plan status done/abandoned) are history and skipped.
359
+ WARN-only, exit code 0, like the rest of the planning-lint family.
360
+ """
361
+ if plan_fm.get("status") in ("done", "abandoned"):
362
+ return
363
+ for section in PLAN_DISCIPLINE_SECTIONS:
364
+ if not _slice_section(plan_body, section).strip():
365
+ print(
366
+ f"WARN: {feature_dir}/PLAN.md: missing '{section}' section "
367
+ f"(planning-discipline; write it or an explicit 'n/a' line). "
368
+ f"See .specfuse/rules/planning-discipline.md."
369
+ )
370
+ for g in gates:
371
+ gate_file = g.get("file")
372
+ if not gate_file:
373
+ continue
374
+ gate_path = feature_dir / gate_file
375
+ if not gate_path.exists():
376
+ continue
377
+ gfm, gbody = read_frontmatter(gate_path)
378
+ if gfm.get("status") == "passed":
379
+ continue
380
+ if not _slice_section(gbody, GATE_ARMING_SECTION).strip():
381
+ print(
382
+ f"WARN: {gate_file}: missing '{GATE_ARMING_SECTION}' section "
383
+ f"(runtime-probe / flag-scope / predicate checks before "
384
+ f"arming). See .specfuse/rules/planning-discipline.md."
385
+ )
386
+
387
+
338
388
  def lint(feature_dir: Path) -> list[str]:
339
389
  errs: list[str] = []
340
390
  plan = feature_dir / "PLAN.md"
@@ -623,6 +673,8 @@ def lint(feature_dir: Path) -> list[str]:
623
673
 
624
674
  # Planned-cost capture: WARN on missing/divergent planned_cost_usd fields.
625
675
  check_planned_cost(feature_dir, fm, gates)
676
+ # Planning-discipline section presence (#201): WARN-only.
677
+ check_planning_sections(feature_dir, fm, body, gates)
626
678
 
627
679
  # Cross-gate mixed-shape check. Two directions of mix:
628
680
  #
@@ -64,7 +64,7 @@ SPECFUSE_DIR = Path(".specfuse")
64
64
  REPO_ROOT = SPECFUSE_DIR.parent
65
65
  FEATURES_DIR = SPECFUSE_DIR / "features"
66
66
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
67
- DRIVER_VERSION = "0.3.22"
67
+ DRIVER_VERSION = "0.3.23"
68
68
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
69
69
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
70
70
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -1537,15 +1537,20 @@ def gate_budget_usd(gate_file: Path) -> float | None:
1537
1537
 
1538
1538
 
1539
1539
  def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
1540
- """Sum lifetime cost across the gate's done WUs (closing-sequence included).
1540
+ """Sum lifetime recorded cost across ALL of the gate's WUs.
1541
1541
 
1542
1542
  Reads each WU file's frontmatter from `gate["work_units"]` and adds
1543
1543
  `cost_usd` PLUS `cumulative_cost_usd` (prior re-arm cycles' spend, folded
1544
- by fold_cumulative_on_rearm — #199) when the WU's status is "done". WUs
1545
- whose frontmatter omits both cost tracking off, or the attempt didn't
1546
- record a cost contribute 0.0. `plan` is the feature frontmatter dict and
1547
- is accepted for signature symmetry with the broader gate-budget helpers;
1548
- the spent total is derived from WU files alone.
1544
+ by fold_cumulative_on_rearm — #199) regardless of WU status (#219):
1545
+ write_cost_to_wu records cost on every outcome pass, blocked, spinning
1546
+ so cost on a non-done WU is real spend, not an estimate. The pre-#219
1547
+ done-only filter left a blocked WU's re-arm burn invisible to the budget
1548
+ brake until the WU finally passed (FEAT-2026-0049/WU-06: $30.29 across 9
1549
+ attempts, contributing $0 while blocked). WUs whose frontmatter omits both
1550
+ fields — cost tracking off, never dispatched, or the attempt didn't record
1551
+ a cost — contribute 0.0. `plan` is the feature frontmatter dict and is
1552
+ accepted for signature symmetry with the broader gate-budget helpers; the
1553
+ spent total is derived from WU files alone.
1549
1554
  """
1550
1555
  del plan # signature symmetry — sum is derived from WU files only
1551
1556
  total = 0.0
@@ -1557,8 +1562,6 @@ def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
1557
1562
  if not wu_path.exists():
1558
1563
  continue
1559
1564
  fm, _ = read_frontmatter(wu_path)
1560
- if fm.get("status") != "done":
1561
- continue
1562
1565
  # Lifetime spend, not per-cycle (#199): fold_cumulative_on_rearm
1563
1566
  # moves each prior dispatch cycle's cost into cumulative_cost_usd and
1564
1567
  # zeroes cost_usd, so summing cost_usd alone undercounts every
@@ -70,6 +70,27 @@ def _write_manifest(specfuse_dir: Path, entries: dict[str, str]) -> None:
70
70
  )
71
71
 
72
72
 
73
+ def _read_manifest(specfuse_dir: Path) -> dict[str, str]:
74
+ """Return the on-disk .scaffold-manifest entries, {} when absent/unreadable.
75
+
76
+ The manifest is the ownership record: a versioned-dir file is provably
77
+ specfuse-written iff its relpath appears here (#214). Malformed content
78
+ degrades to {} — the callers then act as if ownership is unprovable,
79
+ which errs on the side of not deleting.
80
+ """
81
+ manifest_path = specfuse_dir / _MANIFEST_FILE
82
+ if not manifest_path.exists():
83
+ return {}
84
+ try:
85
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
86
+ except (ValueError, OSError):
87
+ return {}
88
+ if not isinstance(data, dict):
89
+ return {}
90
+ return {k: v for k, v in data.items()
91
+ if isinstance(k, str) and isinstance(v, str)}
92
+
93
+
73
94
  def detect_modified(target: str | Path) -> list[str]:
74
95
  """Return sorted relpaths of versioned .specfuse/ files whose sha256 differs from the manifest.
75
96
 
@@ -322,10 +343,12 @@ def upgrade_specfuse(
322
343
  """Overlay versioned seed onto an existing .specfuse/ tree.
323
344
 
324
345
  Overwrites versioned files (templates/, rules/, verification.yml.example, VERSION),
325
- prunes versioned files the seed no longer ships (scoped to templates/ and rules/),
326
- seeds missing user-authored files from templates, and refreshes .claude wiring.
327
- rules-local/ is project-owned: never overlaid, never pruned; only its README
328
- is seeded when absent.
346
+ prunes versioned files the seed no longer ships but only those the prior
347
+ .scaffold-manifest proves specfuse wrote; unknown files are kept with a
348
+ warning (#214) seeds missing user-authored files from templates, and
349
+ refreshes .claude wiring. Overwriting a file whose content is not what the
350
+ prior upgrade wrote warns to stderr. rules-local/ is project-owned: never
351
+ overlaid, never pruned; only its README is seeded when absent.
329
352
 
330
353
  Returns the sorted list of .specfuse/ relpaths written.
331
354
  Raises ScaffoldDowngradeError if the target VERSION is newer than the installed seed.
@@ -350,6 +373,11 @@ def upgrade_specfuse(
350
373
  written: list[str] = []
351
374
  versioned_relpaths: set[str] = set()
352
375
  manifest_entries: dict[str, str] = {}
376
+ # Ownership record from the PRIOR init/upgrade (#214): a file in a
377
+ # versioned dir is provably specfuse-written iff it appears here. Read
378
+ # before the overlay overwrites anything and before _write_manifest
379
+ # replaces it below.
380
+ old_manifest = _read_manifest(specfuse_dir)
353
381
 
354
382
  for relpath, content in iter_scaffold_files():
355
383
  if not (
@@ -358,6 +386,32 @@ def upgrade_specfuse(
358
386
  ):
359
387
  continue
360
388
  dest = specfuse_dir / relpath
389
+ # Clobber warning (#214): overwriting is the versioned-dir contract,
390
+ # but do it loudly when the on-disk content is not what the prior
391
+ # upgrade wrote — either a project-authored file whose name now
392
+ # collides with a shipped one, or a locally-modified shipped file.
393
+ # A missing manifest (legacy pre-manifest tree) stays silent: every
394
+ # file would warn and the signal would drown.
395
+ if old_manifest and dest.exists():
396
+ on_disk_sha = _sha256_hex(dest.read_bytes())
397
+ if on_disk_sha != _sha256_hex(content):
398
+ owned_sha = old_manifest.get(relpath)
399
+ if owned_sha is None:
400
+ print(
401
+ f"specfuse upgrade: WARNING — overwriting {relpath}: "
402
+ "not written by a prior init/upgrade (project-"
403
+ "authored?). Project-owned rules belong in "
404
+ ".specfuse/rules-local/, which upgrade never touches.",
405
+ file=sys.stderr,
406
+ )
407
+ elif on_disk_sha != owned_sha:
408
+ print(
409
+ f"specfuse upgrade: WARNING — overwriting locally-"
410
+ f"modified versioned file {relpath}. Upstream edits "
411
+ "belong in a scaffold PR; project-specific rules in "
412
+ ".specfuse/rules-local/.",
413
+ file=sys.stderr,
414
+ )
361
415
  dest.parent.mkdir(parents=True, exist_ok=True)
362
416
  dest.write_bytes(content)
363
417
  written.append(relpath)
@@ -370,8 +424,24 @@ def upgrade_specfuse(
370
424
  for existing in dir_path.rglob("*"):
371
425
  if existing.is_file():
372
426
  rel = existing.relative_to(specfuse_dir).as_posix()
373
- if rel not in versioned_relpaths:
427
+ if rel in versioned_relpaths:
428
+ continue
429
+ # Manifest-scoped prune (#214): delete only files the
430
+ # prior manifest proves specfuse wrote (shipped once,
431
+ # since removed from the seed). Anything else — a
432
+ # project-authored file, or any file on a legacy
433
+ # pre-manifest tree — is kept, loudly.
434
+ if rel in old_manifest:
374
435
  existing.unlink()
436
+ else:
437
+ print(
438
+ f"specfuse upgrade: WARNING — keeping {rel}: not "
439
+ "in the shipped scaffold and not written by a "
440
+ "prior init/upgrade. If it is project-authored, "
441
+ "move it to .specfuse/rules-local/ (never touched "
442
+ "by upgrade); if it is stale, delete it manually.",
443
+ file=sys.stderr,
444
+ )
375
445
 
376
446
  user_seeds = [
377
447
  ("LEARNINGS.md", "LEARNINGS.template.md"),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.22
3
+ Version: 0.3.23
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
@@ -23,6 +23,7 @@ specfuse/loop/data/docs/methodology.md
23
23
  specfuse/loop/data/docs/skills.md
24
24
  specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md
25
25
  specfuse/loop/data/docs/concepts/ralph-lineage.md
26
+ specfuse/loop/data/rules/close-discipline.md
26
27
  specfuse/loop/data/rules/correlation-ids.md
27
28
  specfuse/loop/data/rules/never-touch.md
28
29
  specfuse/loop/data/rules/planning-discipline.md
@@ -102,6 +103,7 @@ tests/test_lint_oracle_env.py
102
103
  tests/test_lint_plan_errors.py
103
104
  tests/test_lint_plan_next_draft.py
104
105
  tests/test_lint_plan_relative_import.py
106
+ tests/test_lint_planning_sections.py
105
107
  tests/test_lint_produces_driver_helper.py
106
108
  tests/test_lint_sections.py
107
109
  tests/test_lint_task_graph_yaml_selection.py
@@ -34,6 +34,7 @@ _EXPECTED_SPECFUSE_TREE = {
34
34
  "templates/GATE.template.md",
35
35
  "templates/PLAN.template.md",
36
36
  "templates/WU.template.md",
37
+ "rules/close-discipline.md",
37
38
  "rules/correlation-ids.md",
38
39
  "rules/never-touch.md",
39
40
  "rules/planning-discipline.md",
@@ -117,6 +118,7 @@ class TestInitFullLayout(unittest.TestCase):
117
118
 
118
119
  def test_rules_byte_faithful(self):
119
120
  for rel in (
121
+ "rules/close-discipline.md",
120
122
  "rules/correlation-ids.md",
121
123
  "rules/never-touch.md",
122
124
  "rules/planning-discipline.md",
@@ -42,6 +42,14 @@ def _write_plan(feature_dir: Path, gates_yaml: str) -> None:
42
42
  "```yaml\n"
43
43
  f"{gates_yaml}\n"
44
44
  "```\n"
45
+ "\n"
46
+ "## Existing-mechanism search\n"
47
+ "\n"
48
+ "n/a — no enforcement or measurement designed\n"
49
+ "\n"
50
+ "## Escalation-predicate satisfiability\n"
51
+ "\n"
52
+ "n/a — no severity flip\n"
45
53
  )
46
54
 
47
55
 
@@ -0,0 +1,117 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Planning-discipline section-presence lint (#201, second half).
6
+
7
+ WARN-only checks that the sections introduced by the FEAT-2026-0049
8
+ planning-discipline drop (PR #211) are present: PLAN.md carries
9
+ 'Existing-mechanism search' and 'Escalation-predicate satisfiability'
10
+ (an explicit n/a line satisfies them — presence, not content), and each
11
+ non-passed gate file carries 'Arming discipline'. Sealed features
12
+ (plan status done/abandoned) are history and never warn.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import tempfile
19
+ import unittest
20
+ from contextlib import redirect_stdout
21
+ from pathlib import Path
22
+
23
+ from tests._loop_loader import load_lint
24
+
25
+ lint_plan = load_lint()
26
+
27
+ _SECTIONS = (
28
+ "\n## Existing-mechanism search\n\nn/a — no enforcement designed\n"
29
+ "\n## Escalation-predicate satisfiability\n\nn/a — no severity flip\n"
30
+ )
31
+
32
+
33
+ def _write_feature(tmpdir: str, *, plan_status: str = "active",
34
+ plan_sections: str = _SECTIONS,
35
+ gate_status: str = "open",
36
+ gate_body: str = "\n## Arming discipline\n\nprobe notes\n",
37
+ ) -> Path:
38
+ feature = Path(tmpdir) / "feature"
39
+ feature.mkdir(exist_ok=True)
40
+ (feature / "PLAN.md").write_text(
41
+ "---\nfeature_id: FEAT-2026-9998\ntitle: T\nbranch: feat/t\n"
42
+ f"roadmap_goal: g\nstatus: {plan_status}\n"
43
+ "planned_cost_usd: 10.00\n---\n\n# Plan\n\n"
44
+ "```yaml\ngates:\n - gate: 1\n file: GATE-01.md\n"
45
+ " work_units:\n - id: FEAT-2026-9998/T01\n"
46
+ " file: WU-01-impl.md\n depends_on: []\n"
47
+ " - id: FEAT-2026-9998/G1-CLOSE\n file: WU-90-close.md\n"
48
+ " depends_on: [FEAT-2026-9998/T01]\n```\n"
49
+ f"{plan_sections}"
50
+ )
51
+ (feature / "GATE-01.md").write_text(
52
+ f"---\ngate: 1\nstatus: {gate_status}\n---\n\n# Gate 1\n{gate_body}"
53
+ )
54
+ for fname, wid, wtype in (("WU-01-impl.md", "T01", "implementation"),
55
+ ("WU-90-close.md", "G1-CLOSE", "close")):
56
+ (feature / fname).write_text(
57
+ f"---\nid: FEAT-2026-9998/{wid}\ntype: {wtype}\nstatus: done\n"
58
+ f"attempts: 1\nplanned_cost_usd: 5.0\n---\n\n# {wid}\n"
59
+ )
60
+ return feature
61
+
62
+
63
+ def _run_lint(feature: Path) -> tuple[list[str], str]:
64
+ buf = io.StringIO()
65
+ with redirect_stdout(buf):
66
+ errs = lint_plan.lint(feature)
67
+ return errs, buf.getvalue()
68
+
69
+
70
+ class TestPlanningSectionLint(unittest.TestCase):
71
+
72
+ def test_silent_when_sections_present(self):
73
+ with tempfile.TemporaryDirectory() as tmp:
74
+ errs, out = _run_lint(_write_feature(tmp))
75
+ self.assertEqual(errs, [])
76
+ self.assertNotIn("Existing-mechanism", out)
77
+ self.assertNotIn("Arming discipline", out)
78
+
79
+ def test_warns_on_missing_plan_sections(self):
80
+ with tempfile.TemporaryDirectory() as tmp:
81
+ errs, out = _run_lint(_write_feature(tmp, plan_sections=""))
82
+ self.assertEqual(errs, [], "section presence is WARN-only")
83
+ self.assertIn("missing 'Existing-mechanism search'", out)
84
+ self.assertIn("missing 'Escalation-predicate satisfiability'", out)
85
+
86
+ def test_na_line_satisfies_presence(self):
87
+ """Presence, not content: the template's explicit n/a escape passes."""
88
+ with tempfile.TemporaryDirectory() as tmp:
89
+ _, out = _run_lint(_write_feature(tmp, plan_sections=_SECTIONS))
90
+ self.assertNotIn("missing 'Existing-mechanism search'", out)
91
+
92
+ def test_warns_on_gate_missing_arming_section(self):
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ errs, out = _run_lint(_write_feature(tmp, gate_body=""))
95
+ self.assertEqual(errs, [])
96
+ self.assertIn("missing 'Arming discipline'", out)
97
+ self.assertIn("GATE-01.md", out)
98
+
99
+ def test_passed_gate_never_warns(self):
100
+ with tempfile.TemporaryDirectory() as tmp:
101
+ _, out = _run_lint(_write_feature(tmp, gate_status="passed",
102
+ gate_body=""))
103
+ self.assertNotIn("missing 'Arming discipline'", out)
104
+
105
+ def test_sealed_feature_never_warns(self):
106
+ for status in ("done", "abandoned"):
107
+ with self.subTest(status=status):
108
+ with tempfile.TemporaryDirectory() as tmp:
109
+ _, out = _run_lint(_write_feature(
110
+ tmp, plan_status=status, plan_sections="",
111
+ gate_body=""))
112
+ self.assertNotIn("missing 'Existing-mechanism search'", out)
113
+ self.assertNotIn("missing 'Arming discipline'", out)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()
@@ -134,15 +134,20 @@ class TestGateBudgetUsd(unittest.TestCase):
134
134
 
135
135
  class TestGateSpentUsd(unittest.TestCase):
136
136
 
137
- def test_sums_done_wus_only(self):
137
+ def test_sums_recorded_cost_regardless_of_status(self):
138
+ """#219: recorded cost is real spend on every status — a blocked WU's
139
+ re-arm burn must count toward the brake, not wait for done. A WU that
140
+ never dispatched carries no cost fields and contributes 0."""
138
141
  with tempfile.TemporaryDirectory() as tmp:
139
142
  feature = Path(tmp)
140
143
  _write_wu(feature / "WU-T01.md", wu_id="FEAT-2026-9701/T01",
141
144
  status="done", cost_usd=0.5)
145
+ # Blocked mid-gate: write_cost_to_wu recorded its spend.
142
146
  _write_wu(feature / "WU-T02.md", wu_id="FEAT-2026-9701/T02",
143
- status="pending", cost_usd=1.25)
147
+ status="blocked_human", cost_usd=1.25)
148
+ # Never dispatched: no cost fields at all.
144
149
  _write_wu(feature / "WU-T03.md", wu_id="FEAT-2026-9701/T03",
145
- status="done", cost_usd=0.75)
150
+ status="pending", cost_usd=None)
146
151
  gate = {
147
152
  "file": "GATE-01.md",
148
153
  "work_units": [
@@ -152,7 +157,26 @@ class TestGateSpentUsd(unittest.TestCase):
152
157
  ],
153
158
  }
154
159
  spent = loop.gate_spent_usd({}, gate, feature)
155
- self.assertAlmostEqual(spent, 1.25, places=6)
160
+ self.assertAlmostEqual(spent, 1.75, places=6)
161
+
162
+ def test_blocked_wu_cumulative_counts_toward_brake(self):
163
+ """The WU-06 shape: blocked with folded prior-cycle spend — the brake
164
+ must see the full lifetime burn while the WU is still blocked."""
165
+ with tempfile.TemporaryDirectory() as tmp:
166
+ feature = Path(tmp)
167
+ (feature / "WU-T01.md").write_text(
168
+ "---\nid: FEAT-2026-9701/T01\ntype: implementation\n"
169
+ "model: sonnet\nstatus: blocked_human\nattempts: 3\n"
170
+ "cost_usd: 2.75\ncumulative_cost_usd: 27.54\n---\n\n# T01\n"
171
+ )
172
+ gate = {
173
+ "file": "GATE-01.md",
174
+ "work_units": [
175
+ {"id": "FEAT-2026-9701/T01", "file": "WU-T01.md"},
176
+ ],
177
+ }
178
+ self.assertAlmostEqual(
179
+ loop.gate_spent_usd({}, gate, feature), 30.29, places=6)
156
180
 
157
181
  def test_missing_cost_contributes_zero(self):
158
182
  with tempfile.TemporaryDirectory() as tmp:
@@ -23,6 +23,7 @@ TRACKED = {
23
23
  "templates/GATE.template.md",
24
24
  "templates/PLAN.template.md",
25
25
  "templates/WU.template.md",
26
+ "rules/close-discipline.md",
26
27
  "rules/correlation-ids.md",
27
28
  "rules/never-touch.md",
28
29
  "rules/planning-discipline.md",
@@ -91,8 +91,15 @@ class TestUpgradeOverlaysDocs(unittest.TestCase):
91
91
  )
92
92
 
93
93
  def test_upgrade_prunes_removed_docs(self):
94
+ """A docs file the seed once shipped (manifest-owned) but no longer
95
+ carries is pruned; manifest membership is the ownership proof (#214)."""
96
+ import json as _json
94
97
  stray = self.specfuse / "docs" / "obsolete-doc.md"
95
98
  stray.write_bytes(b"old doc")
99
+ manifest = self.specfuse / ".scaffold-manifest"
100
+ entries = _json.loads(manifest.read_text())
101
+ entries["docs/obsolete-doc.md"] = "0" * 64
102
+ manifest.write_text(_json.dumps(entries))
96
103
  upgrade_specfuse(self.target)
97
104
  self.assertFalse(stray.exists(), "stray docs file must be pruned on upgrade")
98
105
 
@@ -16,6 +16,7 @@ _EXPECTED_TREE = {
16
16
  "templates/GATE.template.md",
17
17
  "templates/PLAN.template.md",
18
18
  "templates/WU.template.md",
19
+ "rules/close-discipline.md",
19
20
  "rules/correlation-ids.md",
20
21
  "rules/never-touch.md",
21
22
  "rules/planning-discipline.md",
@@ -45,6 +46,7 @@ _VERSIONED_SEED_TO_TARGET = {
45
46
  "templates/GATE.template.md": "templates/GATE.template.md",
46
47
  "templates/PLAN.template.md": "templates/PLAN.template.md",
47
48
  "templates/WU.template.md": "templates/WU.template.md",
49
+ "rules/close-discipline.md": "rules/close-discipline.md",
48
50
  "rules/correlation-ids.md": "rules/correlation-ids.md",
49
51
  "rules/never-touch.md": "rules/never-touch.md",
50
52
  "rules/planning-discipline.md": "rules/planning-discipline.md",
@@ -18,6 +18,7 @@ _EXPECTED_RELPATHS = {
18
18
  "templates/GATE.template.md",
19
19
  "templates/PLAN.template.md",
20
20
  "templates/WU.template.md",
21
+ "rules/close-discipline.md",
21
22
  "rules/correlation-ids.md",
22
23
  "rules/never-touch.md",
23
24
  "rules/planning-discipline.md",