specfuse-loop 0.3.20__tar.gz → 0.3.21__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 (166) hide show
  1. {specfuse_loop-0.3.20/specfuse_loop.egg-info → specfuse_loop-0.3.21}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.21/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/lint_plan.py +27 -1
  5. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/loop.py +321 -34
  6. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/SOURCES.txt +3 -0
  8. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_attempt_outcome_emission.py +50 -0
  9. specfuse_loop-0.3.21/tests/test_lifetime_accounting.py +240 -0
  10. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_planned_cost_lint.py +72 -0
  11. specfuse_loop-0.3.21/tests/test_produces_in_diff.py +264 -0
  12. specfuse_loop-0.3.21/tests/test_spinning_rearm_gate.py +308 -0
  13. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_terminal_flips.py +85 -0
  14. specfuse_loop-0.3.20/specfuse/loop/data/VERSION +0 -1
  15. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/LICENSE +0 -0
  16. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/NOTICE +0 -0
  17. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/README.md +0 -0
  18. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/setup.cfg +0 -0
  19. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/__init__.py +0 -0
  20. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/_filelock.py +0 -0
  21. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/_miniyaml.py +0 -0
  22. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/adopt_feature.py +0 -0
  23. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  24. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  25. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  26. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/getting-started.md +0 -0
  27. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/methodology.md +0 -0
  28. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/skills.md +0 -0
  29. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/gitignore.snippet +0 -0
  30. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/roadmap.template.md +0 -0
  31. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  32. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/never-touch.md +0 -0
  33. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/result-contract.md +0 -0
  34. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  35. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  36. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/event.schema.json +0 -0
  37. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  38. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  39. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  40. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  41. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/templates/GATE.template.md +0 -0
  42. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  43. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/templates/WU.template.md +0 -0
  44. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/verification.yml.example +0 -0
  45. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/gate_eval.py +0 -0
  46. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/gh_backend.py +0 -0
  47. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/gh_features.py +0 -0
  48. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/scaffold.py +0 -0
  49. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/validate_event.py +0 -0
  50. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  51. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/entry_points.txt +0 -0
  52. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/requires.txt +0 -0
  53. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/top_level.txt +0 -0
  54. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_adopt_feature.py +0 -0
  55. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  56. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autoclose_deferral_visibility.py +0 -0
  57. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync.py +0 -0
  58. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync_consent.py +0 -0
  59. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync_firstrun.py +0 -0
  60. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync_plugin.py +0 -0
  61. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_backend.py +0 -0
  62. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bash_routing.py +0 -0
  63. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  64. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  65. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bookkeeping_no_verify.py +0 -0
  66. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_claude_resolution.py +0 -0
  67. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_close_wu_self_reference.py +0 -0
  68. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_closing_deliverable_guard.py +0 -0
  69. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_console_encoding.py +0 -0
  70. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_cost_tracking.py +0 -0
  71. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_degraded_oracle.py +0 -0
  72. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_deliverable_presence_gate.py +0 -0
  73. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_doctor.py +0 -0
  74. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_driver_integration.py +0 -0
  75. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_driver_lock.py +0 -0
  76. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_duration_tracking.py +0 -0
  77. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_empty_files_escalation.py +0 -0
  78. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_ensure_feature_branch.py +0 -0
  79. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_ensure_feature_branch_base.py +0 -0
  80. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_events_redaction.py +0 -0
  81. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_extra_gates.py +0 -0
  82. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_filelock_portable.py +0 -0
  83. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_force_full_close.py +0 -0
  84. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval.py +0 -0
  85. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval_calibration.py +0 -0
  86. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  87. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval_terminal_wiring.py +0 -0
  88. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gh_backend.py +0 -0
  89. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gh_features.py +0 -0
  90. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_git_env_isolation.py +0 -0
  91. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_hashed_denylist.py +0 -0
  92. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_hashed_denylist_ci.py +0 -0
  93. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_init_integration.py +0 -0
  94. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_interpreter_normalization.py +0 -0
  95. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_leak_findings_redaction.py +0 -0
  96. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_leak_scan.py +0 -0
  97. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_leak_scan_content.py +0 -0
  98. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_learnings_query.py +0 -0
  99. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  100. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lifecycle_integration.py +0 -0
  101. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_bare_produces_path.py +0 -0
  102. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_close_intermediate.py +0 -0
  103. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_close_wu.py +0 -0
  104. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_correlation_id.py +0 -0
  105. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  106. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_oracle_env.py +0 -0
  107. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_plan_errors.py +0 -0
  108. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_plan_next_draft.py +0 -0
  109. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_plan_relative_import.py +0 -0
  110. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_produces_driver_helper.py +0 -0
  111. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_sections.py +0 -0
  112. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  113. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_verification_reference.py +0 -0
  114. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_attempt_evidence.py +0 -0
  115. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_auto_archive.py +0 -0
  116. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_caveman_preamble.py +0 -0
  117. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_close_intermediate.py +0 -0
  118. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_defaults_by_type.py +0 -0
  119. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_effort.py +0 -0
  120. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_failure_note_cap.py +0 -0
  121. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_files_changed_guard.py +0 -0
  122. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_gate_budget.py +0 -0
  123. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_model_alias.py +0 -0
  124. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_orchestration.py +0 -0
  125. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_post_pass_invariant.py +0 -0
  126. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_reset_preserving_events.py +0 -0
  127. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_smoke_runner.py +0 -0
  128. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_unsandboxed.py +0 -0
  129. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_zero_token_guard.py +0 -0
  130. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_migrate_legacy.py +0 -0
  131. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_miniyaml_equivalence.py +0 -0
  132. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_miniyaml_negative.py +0 -0
  133. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_prepare_feature.py +0 -0
  134. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_prepare_scaffold_sync.py +0 -0
  135. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_produces_field.py +0 -0
  136. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_redaction_windows_home.py +0 -0
  137. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_resolve_base.py +0 -0
  138. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_result_block.py +0 -0
  139. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_roadmap_add_skill.py +0 -0
  140. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_roadmap_archive_skill.py +0 -0
  141. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_roadmap_row_parser.py +0 -0
  142. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_data_in_sync.py +0 -0
  143. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_doc_hygiene.py +0 -0
  144. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_docs.py +0 -0
  145. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_init.py +0 -0
  146. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_manifest.py +0 -0
  147. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_resources.py +0 -0
  148. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_seed_sanity.py +0 -0
  149. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_upgrade.py +0 -0
  150. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_wiring.py +0 -0
  151. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_skills_vendored_in_sync.py +0 -0
  152. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_squash_commit_hook_crash.py +0 -0
  153. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_template_closing_shapes.py +0 -0
  154. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_terminal_flip_ownership.py +0 -0
  155. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_timeout_kill_portable.py +0 -0
  156. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_untracked_feature_folder.py +0 -0
  157. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_untracked_file_absorption.py +0 -0
  158. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_upgrade_integration.py +0 -0
  159. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_upgrade_merge_gate.py +0 -0
  160. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_validate_event.py +0 -0
  161. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_verdict_coupling.py +0 -0
  162. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_verify_empty_gate_set.py +0 -0
  163. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_version_consistency.py +0 -0
  164. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_version_skew.py +0 -0
  165. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_windows_gate_exec.py +0 -0
  166. {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/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.20
3
+ Version: 0.3.21
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.20"
7
+ version = "0.3.21"
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.21
@@ -248,12 +248,23 @@ def _find_task_graph_block(body: str) -> dict | None:
248
248
  return None
249
249
 
250
250
 
251
+ # Ceremony WU types systematically under-estimated in the field: close and
252
+ # plan-next WUs ran 2.8-5.2x over on $2-3 estimates across FEAT-2026-0049
253
+ # (clabonte/generator), poisoning every calibration built on the plans (#201).
254
+ # The floor is a WARN threshold, not a cap — estimates below it are almost
255
+ # certainly wishful, not cheap.
256
+ CEREMONY_COST_FLOOR_USD = 5.0
257
+ _CEREMONY_TYPES = frozenset({"close", "close-intermediate", "plan-next"})
258
+
259
+
251
260
  def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
252
261
  """Emit WARN for missing planned_cost_usd on WUs and PLAN.md.
253
262
 
254
263
  Sealed WUs (wu status=done AND plan status=done) are skipped silently —
255
264
  backfilling cost estimates on history is pointless. Active or draft WUs
256
- get the WARN. PLAN.md is compared against the sum of WU planned costs;
265
+ get the WARN. Ceremony-type WUs (close/close-intermediate/plan-next)
266
+ with a planned cost below CEREMONY_COST_FLOOR_USD get a floor WARN
267
+ (#201). PLAN.md is compared against the sum of WU planned costs;
257
268
  delta > 10% emits a separate WARN naming the delta. Never raises or
258
269
  appends to an errors list — all findings are WARN-only (exit code 0).
259
270
  """
@@ -283,6 +294,21 @@ def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
283
294
  )
284
295
  if planned is not None:
285
296
  wu_sum += float(planned)
297
+ # Floor applies to POSITIVE estimates only: the observed
298
+ # failure is a real-but-wishful $2-3 estimate anchoring
299
+ # calibration, not an explicit 0.00 (the scaffold's
300
+ # "unestimated" placeholder, already visible as such).
301
+ if (not is_sealed
302
+ and wfm.get("type") in _CEREMONY_TYPES
303
+ and 0 < float(planned) < CEREMONY_COST_FLOOR_USD):
304
+ print(
305
+ f"WARN: {wfile}: planned_cost_usd "
306
+ f"${float(planned):.2f} is below the "
307
+ f"${CEREMONY_COST_FLOOR_USD:.2f} floor for "
308
+ f"'{wfm.get('type')}' WUs. Ceremony WUs ran 2.8-5.2x "
309
+ f"over on $2-3 estimates (FEAT-2026-0049); raise the "
310
+ f"estimate rather than anchoring calibration on it."
311
+ )
286
312
 
287
313
  wu_sum = round(wu_sum, 2)
288
314
 
@@ -41,6 +41,7 @@ from __future__ import annotations
41
41
 
42
42
  import argparse
43
43
  import datetime as dt
44
+ import fnmatch
44
45
  import hashlib
45
46
  import json
46
47
  import logging
@@ -63,7 +64,7 @@ SPECFUSE_DIR = Path(".specfuse")
63
64
  REPO_ROOT = SPECFUSE_DIR.parent
64
65
  FEATURES_DIR = SPECFUSE_DIR / "features"
65
66
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
66
- DRIVER_VERSION = "0.3.20"
67
+ DRIVER_VERSION = "0.3.21"
67
68
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
68
69
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
69
70
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -624,6 +625,14 @@ def _is_noninformative_signature(sig: "str | None") -> bool:
624
625
  return bool(_FENCE_RE.match(stripped))
625
626
 
626
627
 
628
+ # Maven surefire failing-test line: "[ERROR] SomeClassTest.method:123 ..."
629
+ # (also matches the IT / Tests naming conventions). Group 1 is Class.method —
630
+ # the per-test identity a retry can act on (#207).
631
+ _SUREFIRE_FAILING_TEST_RE = re.compile(
632
+ r"^\[ERROR\]\s+([A-Z]\w*(?:Test|Tests|IT)\w*\.\w+)", re.MULTILINE,
633
+ )
634
+
635
+
627
636
  def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
628
637
  """Extract (failure_class, failure_signature) from gate runner stdout.
629
638
 
@@ -645,14 +654,20 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
645
654
  failure_class = _GATE_CLASS_MAP.get(gate_name, "other")
646
655
  after_lines = stdout[m.end():].splitlines()[:50]
647
656
  after_text = "\n".join(after_lines)
648
- _SIG_PATTERNS: dict[str, re.Pattern[str]] = {
649
- "tests": re.compile(r"^FAIL: (test_\S+)", re.MULTILINE),
650
- "lint": re.compile(r"\b([A-Z]\d{3,4})\b"),
651
- "security": re.compile(r"Issue: \[(B\d+)"),
652
- "coverage": re.compile(r"^([^\s]+\.py)\s+\d+\s+\d+", re.MULTILINE),
657
+ # Per-class patterns, tried in order (#207): pytest/unittest first, then
658
+ # Maven surefire — whose failures previously fell through to the first-
659
+ # informative-line heuristic and collapsed onto a generic Maven line,
660
+ # identical across distinct failing-test sets.
661
+ _SIG_PATTERNS: dict[str, list[re.Pattern[str]]] = {
662
+ "tests": [
663
+ re.compile(r"^FAIL: (test_\S+)", re.MULTILINE),
664
+ _SUREFIRE_FAILING_TEST_RE,
665
+ ],
666
+ "lint": [re.compile(r"\b([A-Z]\d{3,4})\b")],
667
+ "security": [re.compile(r"Issue: \[(B\d+)")],
668
+ "coverage": [re.compile(r"^([^\s]+\.py)\s+\d+\s+\d+", re.MULTILINE)],
653
669
  }
654
- pattern = _SIG_PATTERNS.get(failure_class)
655
- if pattern:
670
+ for pattern in _SIG_PATTERNS.get(failure_class, []):
656
671
  sm = pattern.search(after_text)
657
672
  if sm:
658
673
  sig = sm.group(1)
@@ -703,6 +718,16 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
703
718
  """
704
719
  _KW = re.compile(r"FAIL|Error|Exception|Traceback", re.IGNORECASE)
705
720
  relevant = [ln for ln in stdout.splitlines() if _KW.search(ln)]
721
+ # Hoist surefire failing-test lines to the front (#207): Maven emits
722
+ # dozens of boilerplate [ERROR] wrapper lines that all pass the keyword
723
+ # filter, so the head+tail budget reliably crowded out the actual
724
+ # failing-test list — five consecutive FEAT-2026-0049/T06 retries carried
725
+ # the identical information-free Maven tail while the failing tests sat
726
+ # mid-output. The list is the one part a retry can act on; put it first.
727
+ surefire = [ln for ln in relevant if _SUREFIRE_FAILING_TEST_RE.match(ln)]
728
+ if surefire:
729
+ rest = [ln for ln in relevant if not _SUREFIRE_FAILING_TEST_RE.match(ln)]
730
+ relevant = surefire + rest
706
731
  text = "\n".join(relevant) if relevant else stdout
707
732
  encoded = text.encode("utf-8")
708
733
  if len(encoded) <= max_chars:
@@ -1396,6 +1421,50 @@ def detect_rearm_dispatch(wu: WorkUnit) -> bool:
1396
1421
  return isinstance(cost_usd, (int, float)) and float(cost_usd) > 0
1397
1422
 
1398
1423
 
1424
+ def rearm_reproduction_gate(wu: WorkUnit) -> tuple[bool, str]:
1425
+ """Refuse re-dispatch of a spinning-escalated WU without reproduction (#200).
1426
+
1427
+ After a spinning_signature_repeat escalation the driver used to re-dispatch
1428
+ a re-armed WU with no evidence the escalated failure was ever reproduced in
1429
+ full — FEAT-2026-0049/WU-06 was re-armed twice on test-subset diagnoses;
1430
+ the third re-arm, the first to reproduce the full suite, found the real
1431
+ defect immediately. Before dispatching a WU whose frontmatter carries
1432
+ ``escalation_reason: spinning_signature_repeat`` (stamped at escalation
1433
+ time) with ``re_arm_count > 0``, require one of:
1434
+
1435
+ - ``reproduced_signature`` equal to ``escalation_failure_signature`` —
1436
+ the operator attests the exact escalated failure was reproduced before
1437
+ re-arming, or
1438
+ - ``re_arm_override: true`` — an explicit operator bypass.
1439
+
1440
+ Returns (True, "") when the WU may dispatch (including every WU that never
1441
+ escalated on spinning, and first-time dispatches). Returns (False, reason)
1442
+ when the dispatch must be refused; the caller re-blocks the WU.
1443
+ """
1444
+ if not wu.file.is_file():
1445
+ return True, ""
1446
+ fm, _ = read_frontmatter(wu.file)
1447
+ if fm.get("escalation_reason") != "spinning_signature_repeat":
1448
+ return True, ""
1449
+ re_arm_count = fm.get("re_arm_count", 0)
1450
+ if not isinstance(re_arm_count, int) or isinstance(re_arm_count, bool) \
1451
+ or re_arm_count <= 0:
1452
+ return True, ""
1453
+ if fm.get("re_arm_override") in (True, "true", "True"):
1454
+ return True, ""
1455
+ signature = str(fm.get("escalation_failure_signature") or "")
1456
+ reproduced = str(fm.get("reproduced_signature") or "")
1457
+ if signature and reproduced == signature:
1458
+ return True, ""
1459
+ return False, (
1460
+ f"WU escalated on spinning_signature_repeat "
1461
+ f"(signature: {signature or '<unrecorded>'}) and was re-armed without "
1462
+ f"reproduction evidence. Reproduce the exact failure and set "
1463
+ f"`reproduced_signature: {signature or '<signature>'}` in the WU "
1464
+ f"frontmatter, or set `re_arm_override: true` to bypass explicitly."
1465
+ )
1466
+
1467
+
1399
1468
  def fold_cumulative_on_rearm(wu: WorkUnit, backend: Backend) -> None:
1400
1469
  """Fold the prior dispatch cycle's cost/token/duration into cumulative fields.
1401
1470
 
@@ -1425,6 +1494,22 @@ def fold_cumulative_on_rearm(wu: WorkUnit, backend: Backend) -> None:
1425
1494
  backend.set_wu(wu, "cumulative_input_tokens", cum_input)
1426
1495
  backend.set_wu(wu, "cumulative_output_tokens", cum_output)
1427
1496
 
1497
+ # Lifetime attempts (#199). unblock-wu resets `attempts: 0` BEFORE this
1498
+ # fold runs, so the prior cycle's count is unrecoverable from `attempts`
1499
+ # itself — it survives only in re_arm_history[].prior_attempts. Recompute
1500
+ # (not increment) the sum so the write is idempotent across re-entries,
1501
+ # matching the fold's cost-fold idempotence contract. Entries a pre-#199
1502
+ # unblock-wu wrote without prior_attempts contribute 0.
1503
+ history = fm.get("re_arm_history") or []
1504
+ cum_attempts = 0
1505
+ if isinstance(history, list):
1506
+ for entry in history:
1507
+ if isinstance(entry, dict):
1508
+ prior = entry.get("prior_attempts")
1509
+ if isinstance(prior, int) and not isinstance(prior, bool):
1510
+ cum_attempts += prior
1511
+ backend.set_wu(wu, "cumulative_attempts", cum_attempts)
1512
+
1428
1513
  # Reset per-cycle fields so the new cycle's write_cost_to_wu starts clean.
1429
1514
  backend.set_wu(wu, "cost_usd", 0.0)
1430
1515
  backend.set_wu(wu, "duration_seconds", 0.0)
@@ -1452,14 +1537,15 @@ def gate_budget_usd(gate_file: Path) -> float | None:
1452
1537
 
1453
1538
 
1454
1539
  def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
1455
- """Sum cost_usd across the gate's done WUs (closing-sequence included).
1540
+ """Sum lifetime cost across the gate's done WUs (closing-sequence included).
1456
1541
 
1457
1542
  Reads each WU file's frontmatter from `gate["work_units"]` and adds
1458
- `cost_usd` when the WU's status is "done". WUs whose frontmatter omits
1459
- cost_usdcost tracking off, or the attempt didn't record a cost
1460
- contribute 0.0. `plan` is the feature frontmatter dict and is accepted for
1461
- signature symmetry with the broader gate-budget helpers; the spent total
1462
- is derived from WU files alone.
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.
1463
1549
  """
1464
1550
  del plan # signature symmetry — sum is derived from WU files only
1465
1551
  total = 0.0
@@ -1473,11 +1559,16 @@ def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
1473
1559
  fm, _ = read_frontmatter(wu_path)
1474
1560
  if fm.get("status") != "done":
1475
1561
  continue
1476
- cost = fm.get("cost_usd")
1477
- if isinstance(cost, bool):
1478
- continue
1479
- if isinstance(cost, (int, float)):
1480
- total += float(cost)
1562
+ # Lifetime spend, not per-cycle (#199): fold_cumulative_on_rearm
1563
+ # moves each prior dispatch cycle's cost into cumulative_cost_usd and
1564
+ # zeroes cost_usd, so summing cost_usd alone undercounts every
1565
+ # re-armed WU (FEAT-2026-0049/WU-06 read as $2.75 of a $30.29 spend).
1566
+ for key in ("cost_usd", "cumulative_cost_usd"):
1567
+ cost = fm.get(key)
1568
+ if isinstance(cost, bool):
1569
+ continue
1570
+ if isinstance(cost, (int, float)):
1571
+ total += float(cost)
1481
1572
  return total
1482
1573
 
1483
1574
 
@@ -2611,6 +2702,48 @@ def _legacy_4wu_terminal_close_complete(
2611
2702
  return required.issubset(have_done)
2612
2703
 
2613
2704
 
2705
+ def revert_terminal_surfaces(
2706
+ wu: WorkUnit, feature_dir: Path, repo_root: Path,
2707
+ ) -> list[Path]:
2708
+ """Revert agent-written terminal surfaces after a hedged close verdict (#195).
2709
+
2710
+ A close WU that passes with a hedged verdict (met_locally / partially_met /
2711
+ not_met) must leave every terminal surface un-flipped, but the agent's own
2712
+ close ceremony may already have written PLAN.md `done` and the roadmap row
2713
+ `done` before the driver read the verdict. Reverting only PLAN.md — the
2714
+ pre-#195 behavior — leaves the two surfaces disagreeing about whether the
2715
+ feature is complete. Revert the same surface set fire_terminal_flips owns:
2716
+ PLAN.md status and the roadmap Status cell (the gate file is not flipped
2717
+ until the post-loop awaiting_review write, so there is nothing to revert
2718
+ there). Returns the modified Paths for one bookkeeping commit.
2719
+ """
2720
+ modified: list[Path] = []
2721
+ feature_id = wu.wu_id.rsplit("/", 1)[0]
2722
+
2723
+ plan_path = feature_dir / "PLAN.md"
2724
+ if plan_path.exists():
2725
+ plan_fm, _ = read_frontmatter(plan_path)
2726
+ if plan_fm.get("status") == "done":
2727
+ write_frontmatter_field(plan_path, "status", "active")
2728
+ modified.append(plan_path)
2729
+
2730
+ roadmap_path = repo_root / ".specfuse" / "roadmap.md"
2731
+ if roadmap_path.exists():
2732
+ roadmap_text = roadmap_path.read_text()
2733
+ parsed = _parse_roadmap_row(roadmap_text, feature_id)
2734
+ if parsed is not None and parsed["columns"].get("Status", "") == "done":
2735
+ status_start, status_end = parsed["cell_spans"]["Status"]
2736
+ status_cell = roadmap_text[status_start:status_end]
2737
+ roadmap_path.write_text(
2738
+ roadmap_text[:status_start]
2739
+ + status_cell.replace("done", "active", 1)
2740
+ + roadmap_text[status_end:]
2741
+ )
2742
+ modified.append(roadmap_path)
2743
+
2744
+ return modified
2745
+
2746
+
2614
2747
  def fire_terminal_flips(wu: WorkUnit, feature_dir: Path, repo_root: Path) -> list[Path]:
2615
2748
  """Flip terminal gate → passed, roadmap row → done, call auto_archive_feature.
2616
2749
 
@@ -2621,6 +2754,26 @@ def fire_terminal_flips(wu: WorkUnit, feature_dir: Path, repo_root: Path) -> lis
2621
2754
  modified: set[Path] = set()
2622
2755
  feature_id = wu.wu_id.rsplit("/", 1)[0]
2623
2756
 
2757
+ # Evaluate the verdict BEFORE writing any surface (#195). The pre-#195
2758
+ # shape flipped gate + roadmap unconditionally and only gated the PLAN
2759
+ # flip on the verdict, so a hedged close left a split state (roadmap
2760
+ # `done`, PLAN `active`). A verdict that is present but not `met` now
2761
+ # touches nothing. verdict absent (None) keeps the legacy 4-WU behavior:
2762
+ # those plan-next WUs carry no verdict field and still need the gate and
2763
+ # roadmap flips (their PLAN flip stays with the plan-next agent).
2764
+ disk_verdict = None
2765
+ if wu.file.is_file():
2766
+ wu_fm, _ = read_frontmatter(wu.file)
2767
+ disk_verdict = wu_fm.get("verdict") or None
2768
+ if disk_verdict is not None and not verdict_permits_terminal_flips(disk_verdict):
2769
+ logging.info(
2770
+ "fire_terminal_flips: verdict %r does not permit terminal flips"
2771
+ " — no surface written for %s",
2772
+ disk_verdict,
2773
+ wu.wu_id,
2774
+ )
2775
+ return []
2776
+
2624
2777
  _, gates = load_graph(feature_dir)
2625
2778
  if not gates:
2626
2779
  logging.warning("fire_terminal_flips: no gates in PLAN.md for %s", wu.wu_id)
@@ -2702,15 +2855,11 @@ def fire_terminal_flips(wu: WorkUnit, feature_dir: Path, repo_root: Path) -> lis
2702
2855
  # assert_terminal_flips_fired: the auto-close path writes verdict=met to the
2703
2856
  # WU file via mark_close_wu_auto_closed but leaves the in-memory wu.verdict
2704
2857
  # None, so disk is the authoritative source for both paths.
2705
- # Re-read verdict from disk only when the WU file exists. The legacy 4-WU
2858
+ # disk_verdict was read at the top of this function. The legacy 4-WU
2706
2859
  # close sequence reaches here with a plan-next WU that carries no verdict
2707
- # field (and whose file may be a synthetic stub in tests); a missing file or
2708
- # a non-met verdict simply skips the PLAN flip, leaving legacy behavior
2709
- # unchanged (those features flip PLAN via the plan-next agent, as before).
2710
- disk_verdict = None
2711
- if wu.file.is_file():
2712
- wu_fm, _ = read_frontmatter(wu.file)
2713
- disk_verdict = wu_fm.get("verdict") or None
2860
+ # field (and whose file may be a synthetic stub in tests); a missing file
2861
+ # simply skips the PLAN flip, leaving legacy behavior unchanged (those
2862
+ # features flip PLAN via the plan-next agent, as before).
2714
2863
  if not verdict_permits_terminal_flips(disk_verdict):
2715
2864
  logging.info(
2716
2865
  "fire_terminal_flips: verdict %r does not permit terminal flips"
@@ -3666,6 +3815,35 @@ def assert_declared_deliverables(wu: WorkUnit) -> tuple[bool, str]:
3666
3815
  return True, ""
3667
3816
 
3668
3817
 
3818
+ def assert_produces_in_diff(
3819
+ wu: WorkUnit, touched: list[str],
3820
+ ) -> tuple[bool, str]:
3821
+ """Cross-check declared ``produces:`` entries against the squash diff (#198).
3822
+
3823
+ ``assert_declared_deliverables`` is presence-only, so a WU whose
3824
+ ``produces:`` paths are pre-existing files it was supposed to MODIFY passes
3825
+ it without touching them — the FEAT-2026-0049/T06 shape: ``done`` touching
3826
+ only gate docs while both declared src/main deliverables (existing files)
3827
+ sat unchanged. Every ``produces:`` entry must match at least one path in
3828
+ *touched* (the squash's changed-path list), literally or as a glob.
3829
+ Opt-out mirrors the presence gate: empty ``produces:`` never fires.
3830
+ Returns (False, summary) naming every unmatched entry.
3831
+ """
3832
+ if not wu.produces:
3833
+ return True, ""
3834
+ unmatched = []
3835
+ for raw in wu.produces:
3836
+ entry = str(raw)
3837
+ if not any(t == entry or fnmatch.fnmatch(t, entry) for t in touched):
3838
+ unmatched.append(entry)
3839
+ if unmatched:
3840
+ return False, (
3841
+ "declared produces path(s) not in this WU's squash diff: "
3842
+ + ", ".join(unmatched)
3843
+ )
3844
+ return True, ""
3845
+
3846
+
3669
3847
  # --------------------------------------------------------------------------- #
3670
3848
  # Post-pass driver-state invariants (FEAT-2026-0017/T01) #
3671
3849
  # --------------------------------------------------------------------------- #
@@ -4055,6 +4233,30 @@ def run(
4055
4233
  )])
4056
4234
  # Fall through to existing close-WU dispatch path
4057
4235
 
4236
+ # Spinning re-arm reproduction gate (#200): a WU that escalated
4237
+ # on spinning_signature_repeat may not be re-dispatched until
4238
+ # the exact escalated failure was reproduced (or the operator
4239
+ # explicitly overrode). Fires BEFORE any dispatch state change;
4240
+ # on refusal the WU goes back to blocked_human with an
4241
+ # actionable message and the gate halts as usual.
4242
+ repro_ok, repro_reason = rearm_reproduction_gate(wu)
4243
+ if not repro_ok:
4244
+ backend.set_wu(wu, "status", "blocked_human")
4245
+ flush_events(events_path, [build_event(
4246
+ "re_arm_rejected", wu.wu_id, {
4247
+ "reason": "spinning_reproduction_missing",
4248
+ "detail": repro_reason,
4249
+ })])
4250
+ commit_bookkeeping(
4251
+ [wu.file, events_path],
4252
+ f"chore(loop): {wu.wu_id} re-arm rejected "
4253
+ f"(spinning reproduction missing)"
4254
+ f"\n\nFeature: {wu.wu_id}",
4255
+ )
4256
+ print(f" RE-ARM REJECTED — {repro_reason}")
4257
+ blocked = True
4258
+ continue
4259
+
4058
4260
  head_before = git("rev-parse", "HEAD")
4059
4261
  # Snapshot the operator's untracked files alongside head_before:
4060
4262
  # squash_commit must not absorb WIP that pre-dates this dispatch
@@ -4340,6 +4542,48 @@ def run(
4340
4542
  f"{attempt}/{MAX_ATTEMPTS}"
4341
4543
  )
4342
4544
  continue
4545
+ # Produces-vs-diff cross-check (#198): the presence gate
4546
+ # above cannot see a WU that declared pre-existing files
4547
+ # it never touched (the FEAT-2026-0049/T06 done-without-
4548
+ # delivering shape). Every produces: entry must match a
4549
+ # path in this WU's squash diff; otherwise refuse the
4550
+ # pass, roll back the squash, retry within budget.
4551
+ prod_ok, prod_summary = assert_produces_in_diff(
4552
+ wu, touched,
4553
+ )
4554
+ if not prod_ok:
4555
+ reset_preserving_events(head_before, events_path,
4556
+ untracked_before=untracked_before)
4557
+ _prod_note = (
4558
+ prod_summary
4559
+ + "\n\nEach listed path is a deliverable this WU "
4560
+ "declared in `produces:` but did not change. "
4561
+ "Make the declared change this attempt — do "
4562
+ "not declare done while a deliverable is "
4563
+ "untouched."
4564
+ )
4565
+ _prod_sig = ", ".join(sorted(
4566
+ Path(p).name for p in wu.produces
4567
+ if not any(t == str(p)
4568
+ or fnmatch.fnmatch(t, str(p))
4569
+ for t in touched)
4570
+ ))[:100] or "produces_unchanged"
4571
+ wu_events.append(emit_attempt_outcome(
4572
+ wu, attempt, "produces_not_in_diff",
4573
+ attempts_usage[-1],
4574
+ failure_class="produces_not_in_diff",
4575
+ failure_signature=_prod_sig,
4576
+ failure_excerpt=extract_failure_excerpt(_prod_note),
4577
+ files_touched=touched,
4578
+ extras={"summary": prod_summary},
4579
+ ))
4580
+ attempt_notes.append((attempt, _prod_note))
4581
+ failure_note = _prod_note
4582
+ print(
4583
+ f" PRODUCES NOT IN DIFF attempt "
4584
+ f"{attempt}/{MAX_ATTEMPTS} — {prod_summary}"
4585
+ )
4586
+ continue
4343
4587
  if wu.type == "close":
4344
4588
  # Re-read frontmatter post-squash: the agent writes
4345
4589
  # `verdict:` to the WU file DURING dispatch, but
@@ -4355,14 +4599,19 @@ def run(
4355
4599
  if verdict_permits_terminal_flips(wu.verdict):
4356
4600
  close_wu_for_terminal = wu
4357
4601
  else:
4358
- plan_path = feature_dir / "PLAN.md"
4359
- plan_fm_recheck, _ = read_frontmatter(plan_path)
4360
- if plan_fm_recheck.get("status") == "done":
4361
- write_frontmatter_field(plan_path, "status", "active")
4602
+ # Hedged verdict: revert EVERY terminal surface
4603
+ # the agent's close ceremony may have flipped,
4604
+ # not just PLAN.md (#195 reverting one of two
4605
+ # surfaces left roadmap `done` vs PLAN `active`).
4606
+ reverted = revert_terminal_surfaces(
4607
+ wu, feature_dir, REPO_ROOT,
4608
+ )
4609
+ if reverted:
4362
4610
  commit_bookkeeping(
4363
- [plan_path],
4364
- f"chore(loop): {wu.wu_id} revert PLAN.md done"
4365
- f" (hedged verdict)\n\nFeature: {wu.wu_id}",
4611
+ reverted,
4612
+ f"chore(loop): {wu.wu_id} revert terminal"
4613
+ f" surfaces (hedged verdict)"
4614
+ f"\n\nFeature: {wu.wu_id}",
4366
4615
  )
4367
4616
  elif _legacy_4wu_terminal_close_complete(
4368
4617
  wu, units, gate, gates,
@@ -4401,9 +4650,38 @@ def run(
4401
4650
  agent_status="complete",
4402
4651
  agent_blocked_reason=None,
4403
4652
  ))
4653
+ # Lifetime fields (#199): a retrospective must be able
4654
+ # to compute rework-vs-new-work and planned-vs-actual
4655
+ # per WU from this one event, without reconciling
4656
+ # re-arm generations by hand. cumulative_* frontmatter
4657
+ # holds prior cycles' totals (folded at re-arm
4658
+ # dispatch); this cycle's numbers are added here.
4659
+ _fm_done, _ = read_frontmatter(wu.file)
4660
+ _prior_cost = _fm_done.get("cumulative_cost_usd")
4661
+ _prior_cost = (float(_prior_cost)
4662
+ if isinstance(_prior_cost, (int, float))
4663
+ and not isinstance(_prior_cost, bool)
4664
+ else 0.0)
4665
+ _prior_attempts = _fm_done.get("cumulative_attempts")
4666
+ _prior_attempts = (int(_prior_attempts)
4667
+ if isinstance(_prior_attempts, int)
4668
+ and not isinstance(_prior_attempts, bool)
4669
+ else 0)
4670
+ _planned = _fm_done.get("planned_cost_usd")
4671
+ _planned = (float(_planned)
4672
+ if isinstance(_planned, (int, float))
4673
+ and not isinstance(_planned, bool)
4674
+ else None)
4404
4675
  wu_events.append(build_event("task_completed", wu.wu_id, {
4405
4676
  "attempts": attempt,
4406
4677
  "attempts_usage": attempts_usage,
4678
+ "type": wu.type,
4679
+ "re_arm_count": re_arm_count,
4680
+ "cost_usd": round(cum_usage["cost_usd"], 6),
4681
+ "cumulative_cost_usd": round(
4682
+ _prior_cost + cum_usage["cost_usd"], 6),
4683
+ "attempts_lifetime": _prior_attempts + attempt,
4684
+ "planned_cost_usd": _planned,
4407
4685
  }))
4408
4686
  flush_events(events_path, wu_events)
4409
4687
  done_ids.add(wu.wu_id)
@@ -4508,6 +4786,15 @@ def run(
4508
4786
  note_paths = persist_attempt_notes(
4509
4787
  work_dir, wu.wu_id, attempt_notes)
4510
4788
  backend.set_wu(wu, "status", "blocked_human")
4789
+ # Stamp the escalation identity into frontmatter (#200):
4790
+ # the re-arm reproduction gate needs the exact signature
4791
+ # at the NEXT dispatch, and events.jsonl is not read on
4792
+ # the dispatch path. Rides the same bookkeeping commit.
4793
+ backend.set_wu(wu, "escalation_reason",
4794
+ "spinning_signature_repeat")
4795
+ backend.set_wu(wu, "escalation_failure_class", _fc or "")
4796
+ backend.set_wu(wu, "escalation_failure_signature",
4797
+ _fs or "")
4511
4798
  write_cost_to_wu(backend, wu, cum_usage)
4512
4799
  flush_events(events_path, wu_events)
4513
4800
  commit_bookkeeping(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.20
3
+ Version: 0.3.21
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
@@ -90,6 +90,7 @@ tests/test_leak_scan_content.py
90
90
  tests/test_learnings_query.py
91
91
  tests/test_legacy_4wu_terminal_flips.py
92
92
  tests/test_lifecycle_integration.py
93
+ tests/test_lifetime_accounting.py
93
94
  tests/test_lint_bare_produces_path.py
94
95
  tests/test_lint_close_intermediate.py
95
96
  tests/test_lint_close_wu.py
@@ -126,6 +127,7 @@ tests/test_planned_cost_lint.py
126
127
  tests/test_prepare_feature.py
127
128
  tests/test_prepare_scaffold_sync.py
128
129
  tests/test_produces_field.py
130
+ tests/test_produces_in_diff.py
129
131
  tests/test_redaction_windows_home.py
130
132
  tests/test_resolve_base.py
131
133
  tests/test_result_block.py
@@ -142,6 +144,7 @@ tests/test_scaffold_seed_sanity.py
142
144
  tests/test_scaffold_upgrade.py
143
145
  tests/test_scaffold_wiring.py
144
146
  tests/test_skills_vendored_in_sync.py
147
+ tests/test_spinning_rearm_gate.py
145
148
  tests/test_squash_commit_hook_crash.py
146
149
  tests/test_template_closing_shapes.py
147
150
  tests/test_terminal_flip_ownership.py
@@ -220,6 +220,35 @@ class TestParseGateFailureSignature(unittest.TestCase):
220
220
  self.assertEqual(fc, "coverage")
221
221
  self.assertEqual(sig, "file.py")
222
222
 
223
+ def test_tests_fail_extracts_surefire_failing_test(self):
224
+ """#207: surefire failures name Class.method, not 'FAIL: test_*' —
225
+ the signature must carry the first failing test, not a generic
226
+ Maven line identical across distinct failures."""
227
+ stdout = (
228
+ "### tests: FAIL\n"
229
+ "[ERROR] Tests run: 431, Failures: 1, Errors: 0, Skipped: 0\n"
230
+ "[ERROR] RelationshipSymmetryTest.rejectsAsymmetric:123 "
231
+ "expected ERROR but was WARNING\n"
232
+ )
233
+ fc, sig = loop.parse_gate_failure_signature(stdout)
234
+ self.assertEqual(fc, "tests")
235
+ self.assertEqual(sig, "RelationshipSymmetryTest.rejectsAsymmetric")
236
+
237
+ def test_distinct_surefire_failures_yield_distinct_signatures(self):
238
+ base = "### tests: FAIL\n[ERROR] Tests run: 10, Failures: 1\n"
239
+ _, sig_a = loop.parse_gate_failure_signature(
240
+ base + "[ERROR] AlphaTest.one:1 boom\n")
241
+ _, sig_b = loop.parse_gate_failure_signature(
242
+ base + "[ERROR] BetaTest.two:2 boom\n")
243
+ self.assertNotEqual(sig_a, sig_b)
244
+
245
+ def test_pytest_pattern_still_wins_over_surefire(self):
246
+ stdout = ("### tests: FAIL\n"
247
+ "FAIL: test_foo\n"
248
+ "[ERROR] SomeTest.method:1 noise\n")
249
+ _, sig = loop.parse_gate_failure_signature(stdout)
250
+ self.assertEqual(sig, "test_foo")
251
+
223
252
  def test_unknown_gate_name_returns_other(self):
224
253
  stdout = "### custom-gate: FAIL\nsome output"
225
254
  fc, _sig = loop.parse_gate_failure_signature(stdout)
@@ -304,6 +333,27 @@ class TestExtractFailureExcerpt(unittest.TestCase):
304
333
  self.fail(f"UnicodeDecodeError raised: {e}")
305
334
  self.assertIsInstance(excerpt, str)
306
335
 
336
+ def test_surefire_failing_tests_hoisted_to_front(self):
337
+ """#207: Maven boilerplate [ERROR] lines crowd the head+tail budget;
338
+ the surefire failing-test lines must survive truncation by being
339
+ hoisted to the front of the relevant-line set."""
340
+ lines = ["[ERROR] Maven boilerplate wrapper line " + "x" * 40
341
+ for _ in range(200)]
342
+ # Failing tests buried mid-output — where the pre-#207 budget lost them.
343
+ lines.insert(100, "[ERROR] RelationshipSymmetryTest.rejectsAsymmetric:123 "
344
+ "expected ERROR but was WARNING")
345
+ lines.insert(101, "[ERROR] FkInferenceTest.retiresImplicit:45 "
346
+ "NullPointerException")
347
+ excerpt = loop.extract_failure_excerpt("\n".join(lines), max_chars=500)
348
+ self.assertIn("RelationshipSymmetryTest.rejectsAsymmetric", excerpt)
349
+ self.assertIn("FkInferenceTest.retiresImplicit", excerpt)
350
+
351
+ def test_no_surefire_lines_keeps_existing_order(self):
352
+ """Without surefire lines the excerpt behaviour is unchanged."""
353
+ stdout = "Error: alpha\nError: beta"
354
+ excerpt = loop.extract_failure_excerpt(stdout)
355
+ self.assertEqual(excerpt, "Error: alpha\nError: beta")
356
+
307
357
  def test_keeps_head_and_tail_on_truncation(self):
308
358
  # #175: build-tool output puts the offending-file list up top and
309
359
  # boilerplate (help URLs, stack hints) at the bottom. A tail-only