specfuse-loop 0.3.15__tar.gz → 0.3.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. {specfuse_loop-0.3.15/specfuse_loop.egg-info → specfuse_loop-0.3.17}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.17/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/loop.py +190 -24
  5. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/scaffold.py +37 -0
  6. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  8. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_attempt_outcome_emission.py +82 -0
  9. specfuse_loop-0.3.17/tests/test_loop_attempt_evidence.py +116 -0
  10. specfuse_loop-0.3.17/tests/test_loop_reset_preserving_events.py +294 -0
  11. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_upgrade.py +66 -0
  12. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_untracked_file_absorption.py +5 -0
  13. specfuse_loop-0.3.15/specfuse/loop/data/VERSION +0 -1
  14. specfuse_loop-0.3.15/tests/test_loop_reset_preserving_events.py +0 -142
  15. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/LICENSE +0 -0
  16. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/NOTICE +0 -0
  17. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/README.md +0 -0
  18. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/setup.cfg +0 -0
  19. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/__init__.py +0 -0
  20. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/_filelock.py +0 -0
  21. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/_miniyaml.py +0 -0
  22. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/adopt_feature.py +0 -0
  23. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  24. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  25. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  26. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/getting-started.md +0 -0
  27. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/methodology.md +0 -0
  28. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/docs/skills.md +0 -0
  29. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/gitignore.snippet +0 -0
  30. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/roadmap.template.md +0 -0
  31. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  32. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/never-touch.md +0 -0
  33. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/result-contract.md +0 -0
  34. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  35. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  36. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/event.schema.json +0 -0
  37. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  38. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  39. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  40. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  41. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/GATE.template.md +0 -0
  42. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  43. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/templates/WU.template.md +0 -0
  44. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/data/verification.yml.example +0 -0
  45. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/gate_eval.py +0 -0
  46. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/gh_backend.py +0 -0
  47. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/gh_features.py +0 -0
  48. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/lint_plan.py +0 -0
  49. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse/loop/validate_event.py +0 -0
  50. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  51. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/entry_points.txt +0 -0
  52. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/requires.txt +0 -0
  53. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/specfuse_loop.egg-info/top_level.txt +0 -0
  54. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_adopt_feature.py +0 -0
  55. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  56. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_autoclose_deferral_visibility.py +0 -0
  57. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_autosync.py +0 -0
  58. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_autosync_consent.py +0 -0
  59. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_autosync_firstrun.py +0 -0
  60. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_autosync_plugin.py +0 -0
  61. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_backend.py +0 -0
  62. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_bash_routing.py +0 -0
  63. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  64. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  65. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_bookkeeping_no_verify.py +0 -0
  66. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_claude_resolution.py +0 -0
  67. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_close_wu_self_reference.py +0 -0
  68. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_closing_deliverable_guard.py +0 -0
  69. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_console_encoding.py +0 -0
  70. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_cost_tracking.py +0 -0
  71. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_degraded_oracle.py +0 -0
  72. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_deliverable_presence_gate.py +0 -0
  73. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_doctor.py +0 -0
  74. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_driver_integration.py +0 -0
  75. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_driver_lock.py +0 -0
  76. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_duration_tracking.py +0 -0
  77. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_empty_files_escalation.py +0 -0
  78. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_ensure_feature_branch.py +0 -0
  79. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_ensure_feature_branch_base.py +0 -0
  80. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_events_redaction.py +0 -0
  81. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_extra_gates.py +0 -0
  82. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_filelock_portable.py +0 -0
  83. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_force_full_close.py +0 -0
  84. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_gate_eval.py +0 -0
  85. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_gate_eval_calibration.py +0 -0
  86. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  87. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_gate_eval_terminal_wiring.py +0 -0
  88. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_gh_backend.py +0 -0
  89. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_gh_features.py +0 -0
  90. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_git_env_isolation.py +0 -0
  91. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_hashed_denylist.py +0 -0
  92. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_hashed_denylist_ci.py +0 -0
  93. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_init_integration.py +0 -0
  94. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_interpreter_normalization.py +0 -0
  95. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_leak_findings_redaction.py +0 -0
  96. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_leak_scan.py +0 -0
  97. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_leak_scan_content.py +0 -0
  98. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_learnings_query.py +0 -0
  99. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  100. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lifecycle_integration.py +0 -0
  101. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_bare_produces_path.py +0 -0
  102. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_close_intermediate.py +0 -0
  103. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_close_wu.py +0 -0
  104. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_correlation_id.py +0 -0
  105. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  106. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_oracle_env.py +0 -0
  107. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_plan_errors.py +0 -0
  108. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_plan_next_draft.py +0 -0
  109. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_plan_relative_import.py +0 -0
  110. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_produces_driver_helper.py +0 -0
  111. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_sections.py +0 -0
  112. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  113. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_auto_archive.py +0 -0
  114. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_caveman_preamble.py +0 -0
  115. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_close_intermediate.py +0 -0
  116. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_defaults_by_type.py +0 -0
  117. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_effort.py +0 -0
  118. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_failure_note_cap.py +0 -0
  119. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_files_changed_guard.py +0 -0
  120. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_gate_budget.py +0 -0
  121. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_model_alias.py +0 -0
  122. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_orchestration.py +0 -0
  123. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_post_pass_invariant.py +0 -0
  124. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_smoke_runner.py +0 -0
  125. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_unsandboxed.py +0 -0
  126. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_loop_zero_token_guard.py +0 -0
  127. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_migrate_legacy.py +0 -0
  128. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_miniyaml_equivalence.py +0 -0
  129. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_miniyaml_negative.py +0 -0
  130. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_planned_cost_lint.py +0 -0
  131. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_prepare_feature.py +0 -0
  132. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_prepare_scaffold_sync.py +0 -0
  133. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_produces_field.py +0 -0
  134. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_redaction_windows_home.py +0 -0
  135. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_resolve_base.py +0 -0
  136. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_result_block.py +0 -0
  137. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_roadmap_add_skill.py +0 -0
  138. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_roadmap_archive_skill.py +0 -0
  139. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_roadmap_row_parser.py +0 -0
  140. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_data_in_sync.py +0 -0
  141. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_doc_hygiene.py +0 -0
  142. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_docs.py +0 -0
  143. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_init.py +0 -0
  144. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_manifest.py +0 -0
  145. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_resources.py +0 -0
  146. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_seed_sanity.py +0 -0
  147. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_scaffold_wiring.py +0 -0
  148. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_skills_vendored_in_sync.py +0 -0
  149. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_squash_commit_hook_crash.py +0 -0
  150. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_template_closing_shapes.py +0 -0
  151. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_terminal_flip_ownership.py +0 -0
  152. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_terminal_flips.py +0 -0
  153. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_timeout_kill_portable.py +0 -0
  154. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_untracked_feature_folder.py +0 -0
  155. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/tests/test_windows_gate_exec.py +0 -0
  163. {specfuse_loop-0.3.15 → specfuse_loop-0.3.17}/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.15
3
+ Version: 0.3.17
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.15"
7
+ version = "0.3.17"
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.17
@@ -63,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
63
63
  REPO_ROOT = SPECFUSE_DIR.parent
64
64
  FEATURES_DIR = SPECFUSE_DIR / "features"
65
65
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
66
- DRIVER_VERSION = "0.3.15"
66
+ DRIVER_VERSION = "0.3.17"
67
67
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
68
68
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
69
69
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -542,6 +542,36 @@ def detect_degraded_oracle(stdout: str) -> str | None:
542
542
  return None
543
543
 
544
544
 
545
+ # A failure signature carries no distinguishing content when it is a bare
546
+ # code/markdown fence, whitespace-only, or pure ANSI. Non-Python gate output
547
+ # (e.g. a TypeScript `tests` gate) matches none of the Python-specific
548
+ # _SIG_PATTERNS below and used to fall through to the first line after the
549
+ # marker — often a ``` fence — collapsing genuinely different failures to one
550
+ # value and false-firing spinning_signature_repeat (#167).
551
+ _FENCE_RE = re.compile(r"^[`~]{3,}[\w.+-]*$")
552
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
553
+ # Sentinel returned when every line after the FAIL marker is non-informative.
554
+ # Distinct from `no_gate_marker` (a marker WAS found) and treated as
555
+ # non-informative by `_is_noninformative_signature`.
556
+ NO_SIGNATURE = "no_signature"
557
+
558
+
559
+ def _is_noninformative_signature(sig: "str | None") -> bool:
560
+ """True when *sig* cannot distinguish one failure from another.
561
+
562
+ Covers None, whitespace-only, bare fences (``` / ~~~, optionally with a
563
+ language tag), pure-ANSI strings, and the NO_SIGNATURE sentinel. The spin
564
+ detector and the extraction fallback both consult this so a content-free
565
+ signature neither becomes a WU's identity nor trips escalation (#167).
566
+ """
567
+ if sig is None or sig == NO_SIGNATURE:
568
+ return True
569
+ stripped = _ANSI_RE.sub("", sig).strip()
570
+ if not stripped:
571
+ return True
572
+ return bool(_FENCE_RE.match(stripped))
573
+
574
+
545
575
  def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
546
576
  """Extract (failure_class, failure_signature) from gate runner stdout.
547
577
 
@@ -577,9 +607,12 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
577
607
  return failure_class, sig if sig else "unknown"
578
608
  for line in after_lines:
579
609
  stripped = line.strip()
580
- if stripped:
581
- return failure_class, stripped[:100]
582
- return failure_class, "unknown"
610
+ # Skip non-informative lines (bare fences, whitespace, pure ANSI):
611
+ # keying the signature off one would collapse distinct failures (#167).
612
+ if _is_noninformative_signature(stripped):
613
+ continue
614
+ return failure_class, stripped[:100]
615
+ return failure_class, NO_SIGNATURE
583
616
 
584
617
 
585
618
  def detect_spinning_signature_repeat(
@@ -592,6 +625,9 @@ def detect_spinning_signature_repeat(
592
625
  Returns False when either element of current is None.
593
626
  Returns False when current or prior is the no_gate_marker sentinel to
594
627
  avoid false-positive halts on parser-opaque failures (AC4).
628
+ Returns False when either signature is non-informative — a bare fence,
629
+ whitespace, pure ANSI, or the NO_SIGNATURE sentinel — since such values
630
+ collapse distinct failures and would false-fire the spin halt (#167).
595
631
  """
596
632
  _SENTINEL = ("other", "no_gate_marker")
597
633
  if prior is None:
@@ -600,6 +636,9 @@ def detect_spinning_signature_repeat(
600
636
  return False
601
637
  if current == _SENTINEL or prior == _SENTINEL:
602
638
  return False
639
+ if (_is_noninformative_signature(current[1])
640
+ or _is_noninformative_signature(prior[1])):
641
+ return False
603
642
  return current == prior
604
643
 
605
644
 
@@ -1410,7 +1449,49 @@ def commit_bookkeeping(paths: list, message: str) -> str | None:
1410
1449
  return git("rev-parse", "HEAD")
1411
1450
 
1412
1451
 
1413
- def reset_preserving_events(head_before: str, events_path: Path) -> None:
1452
+ def _clean_attempt_untracked(
1453
+ untracked_before: "set[str] | frozenset[str]", events_path: Path,
1454
+ ) -> None:
1455
+ """Delete untracked files that appeared since the *untracked_before* snapshot.
1456
+
1457
+ Helper for `reset_preserving_events`; see its docstring for why. Deletes
1458
+ FILES only — empty directories are left behind, which is harmless (git
1459
+ does not track directories) and avoids racing the driver's own mid-run
1460
+ scratch dirs.
1461
+
1462
+ Never deletes: paths in *untracked_before* (the operator's, per #150),
1463
+ *events_path* (driver-managed, and untracked on a fresh branch), or
1464
+ gitignored paths (`untracked_paths` honors .gitignore, so a feature's
1465
+ `work/` scratch dir never enters the delete set).
1466
+
1467
+ Best-effort: any git or filesystem error is swallowed. A failure to clean
1468
+ reverts to the old leftover behavior, which must not crash the run.
1469
+ """
1470
+ try:
1471
+ root = Path(git("rev-parse", "--show-toplevel"))
1472
+ candidates = untracked_paths() - set(untracked_before)
1473
+ except (subprocess.CalledProcessError, OSError):
1474
+ return
1475
+ try:
1476
+ keep = events_path.resolve()
1477
+ except OSError:
1478
+ keep = None
1479
+ for rel in sorted(candidates):
1480
+ target = root / rel
1481
+ try:
1482
+ if keep is not None and target.resolve() == keep:
1483
+ continue
1484
+ if target.is_file() or target.is_symlink():
1485
+ target.unlink()
1486
+ except OSError:
1487
+ continue
1488
+
1489
+
1490
+ def reset_preserving_events(
1491
+ head_before: str,
1492
+ events_path: Path,
1493
+ untracked_before: "set[str] | frozenset[str] | None" = None,
1494
+ ) -> None:
1414
1495
  """`git reset --hard <head_before>` without losing events.jsonl content.
1415
1496
 
1416
1497
  The hard-reset is the methodology's "wipe agent's edits before we write our
@@ -1433,11 +1514,72 @@ def reset_preserving_events(head_before: str, events_path: Path) -> None:
1433
1514
 
1434
1515
  Subsequent `flush_events` calls then append to the preserved content;
1435
1516
  `commit_bookkeeping` captures the full history.
1517
+
1518
+ `untracked_before` (#162) is the per-WU untracked snapshot taken at
1519
+ dispatch. When supplied, untracked files that appeared SINCE it are
1520
+ deleted after the reset. A hard reset rolls back tracked content but
1521
+ leaves untracked files on disk, and the snapshot is taken once per WU
1522
+ (outside the attempt loop) — so without this, attempt 1's new files
1523
+ survive into attempt 2, where they are indistinguishable from attempt 2's
1524
+ own work: `verify_files_changed` passes them and `squash_commit` commits
1525
+ them, letting an attempt pass on its predecessor's leftovers.
1526
+
1527
+ Absence is inert BY DESIGN. An empty set would read as "the operator had
1528
+ nothing untracked" and delete the entire untracked working tree; `None`
1529
+ means "no snapshot available, do not clean" and preserves the pre-#162
1530
+ behavior for callers that cannot supply one.
1436
1531
  """
1437
1532
  saved = events_path.read_text() if events_path.is_file() else None
1438
1533
  git("reset", "--hard", head_before)
1439
1534
  if saved is not None:
1440
1535
  events_path.write_text(saved)
1536
+ if untracked_before is not None:
1537
+ _clean_attempt_untracked(untracked_before, events_path)
1538
+
1539
+
1540
+ def capture_working_tree_diff(head_before: str, max_chars: int = 20000) -> str:
1541
+ """Return `git diff <head_before>` (working tree vs pre-attempt base), capped.
1542
+
1543
+ Folded into a failing attempt's evidence note BEFORE the per-attempt
1544
+ `git reset --hard` discards the rejected work, so a blocked WU stays
1545
+ diagnosable — a human (or /gate-status) can see the diff that was rolled
1546
+ back, not just a truncated failure excerpt (#168).
1547
+
1548
+ Best-effort: returns "" on any git error or a clean tree. Truncated to
1549
+ max_chars so a large refactor's diff cannot bloat the committed note.
1550
+ """
1551
+ try:
1552
+ out = subprocess.run(
1553
+ ["git", "diff", head_before],
1554
+ capture_output=True, text=True, check=True,
1555
+ ).stdout
1556
+ except (subprocess.CalledProcessError, OSError):
1557
+ return ""
1558
+ if len(out) > max_chars:
1559
+ out = out[:max_chars] + "\n… (diff truncated)\n"
1560
+ return out
1561
+
1562
+
1563
+ def persist_attempt_notes(
1564
+ work_dir: Path, wu_id: str, attempt_notes: "list[tuple[int, str]]",
1565
+ ) -> "list[Path]":
1566
+ """Write buffered per-attempt evidence to work/<wu>/attempt-N.md.
1567
+
1568
+ Extracted so both escalation paths persist evidence the same way: the
1569
+ exhausted-attempts `for-else` and the early `spinning_signature_repeat`
1570
+ halt. Previously only the former wrote notes, so a signature-repeat block
1571
+ left nothing on disk to diagnose (#168). Returns the written paths for
1572
+ inclusion in the escalation commit. `work/` is gitignored, so the explicit
1573
+ `git add` in commit_bookkeeping is what tracks these.
1574
+ """
1575
+ wu_key = wu_id.replace("/", "_")
1576
+ paths: list[Path] = []
1577
+ for atmpt, evidence in attempt_notes:
1578
+ p = work_dir / wu_key / f"attempt-{atmpt}.md"
1579
+ p.parent.mkdir(parents=True, exist_ok=True)
1580
+ p.write_text(evidence)
1581
+ paths.append(p)
1582
+ return paths
1441
1583
 
1442
1584
 
1443
1585
  class SquashCommitError(RuntimeError):
@@ -3801,7 +3943,8 @@ def run(
3801
3943
  wu, attempt, "zero_token_skip",
3802
3944
  attempts_usage[-1],
3803
3945
  ))
3804
- reset_preserving_events(head_before, events_path)
3946
+ reset_preserving_events(head_before, events_path,
3947
+ untracked_before=untracked_before)
3805
3948
  print(f" ZERO-TOKEN attempt {attempt}/{MAX_ATTEMPTS} "
3806
3949
  f"— no agent output, skipping")
3807
3950
  continue
@@ -3812,7 +3955,8 @@ def run(
3812
3955
  # reset wipe the flip — the silent-state-loss bug.
3813
3956
  # Use reset_preserving_events to keep prior WU's
3814
3957
  # flushed-but-uncommitted events.jsonl entries.
3815
- reset_preserving_events(head_before, events_path)
3958
+ reset_preserving_events(head_before, events_path,
3959
+ untracked_before=untracked_before)
3816
3960
  backend.set_wu(wu, "status", "blocked_human")
3817
3961
  write_cost_to_wu(backend, wu, cum_usage)
3818
3962
  wu_events.append(emit_attempt_outcome(
@@ -3856,7 +4000,8 @@ def run(
3856
4000
  # written just above — record the failure with git's
3857
4001
  # stderr, and retry within budget (MAX_ATTEMPTS
3858
4002
  # exhaustion escalates to blocked_human).
3859
- reset_preserving_events(head_before, events_path)
4003
+ reset_preserving_events(head_before, events_path,
4004
+ untracked_before=untracked_before)
3860
4005
  # Redact any quoted leak-scan match before it lands
3861
4006
  # in events.jsonl / the attempt note, so the captured
3862
4007
  # FINDINGS text can't re-trip the next bookkeeping
@@ -3887,7 +4032,8 @@ def run(
3887
4032
  smoke_cmds, Path("."),
3888
4033
  )
3889
4034
  if not smoke_ok:
3890
- reset_preserving_events(head_before, events_path)
4035
+ reset_preserving_events(head_before, events_path,
4036
+ untracked_before=untracked_before)
3891
4037
  wu_events.append(emit_attempt_outcome(
3892
4038
  wu, attempt, "smoke_import_failed",
3893
4039
  attempts_usage[-1],
@@ -3904,7 +4050,8 @@ def run(
3904
4050
  wu, feature_dir, REPO_ROOT, head_before,
3905
4051
  )
3906
4052
  if not closing_ok:
3907
- reset_preserving_events(head_before, events_path)
4053
+ reset_preserving_events(head_before, events_path,
4054
+ untracked_before=untracked_before)
3908
4055
  # `assertion` names the primary (first) failing
3909
4056
  # assertion. With aggregation (#72) closing_summary
3910
4057
  # may list several; the first `assert_*` token is the
@@ -3940,7 +4087,8 @@ def run(
3940
4087
  # every current WU is unchanged.
3941
4088
  deliv_ok, deliv_summary = assert_declared_deliverables(wu)
3942
4089
  if not deliv_ok:
3943
- reset_preserving_events(head_before, events_path)
4090
+ reset_preserving_events(head_before, events_path,
4091
+ untracked_before=untracked_before)
3944
4092
  missing = deliv_summary.split(": ", 1)[-1]
3945
4093
  wu_events.append(emit_attempt_outcome(
3946
4094
  wu, attempt, "deliverable_missing",
@@ -3966,7 +4114,8 @@ def run(
3966
4114
  wu, touched,
3967
4115
  )
3968
4116
  if not impl_ok:
3969
- reset_preserving_events(head_before, events_path)
4117
+ reset_preserving_events(head_before, events_path,
4118
+ untracked_before=untracked_before)
3970
4119
  wu_events.append(emit_attempt_outcome(
3971
4120
  wu, attempt, "no_deliverable_files",
3972
4121
  attempts_usage[-1],
@@ -4068,7 +4217,8 @@ def run(
4068
4217
  ))
4069
4218
  attempt_notes.append((attempt, note))
4070
4219
  failure_note = note
4071
- reset_preserving_events(head_before, events_path)
4220
+ reset_preserving_events(head_before, events_path,
4221
+ untracked_before=untracked_before)
4072
4222
  print(f" FILES_CHANGED MISMATCH attempt "
4073
4223
  f"{attempt}/{MAX_ATTEMPTS} — {len(payload)} path(s) "
4074
4224
  f"unchanged")
@@ -4078,7 +4228,19 @@ def run(
4078
4228
  # Per-attempt notes are buffered (not written to disk) so they
4079
4229
  # ride with the spinning-escalation commit if we exhaust
4080
4230
  # attempts; on eventual PASS they're discarded as scratch.
4081
- attempt_notes.append((attempt, payload))
4231
+ # Fold the rejected working-tree diff into the buffered
4232
+ # evidence NOW, before the reset below discards it (#168).
4233
+ # Signature/excerpt parsing stays on the raw payload.
4234
+ _diff = capture_working_tree_diff(head_before)
4235
+ _evidence = payload
4236
+ if _diff:
4237
+ _evidence = (
4238
+ payload
4239
+ + "\n\n## Rejected working-tree diff "
4240
+ "(discarded by git reset on block)\n\n"
4241
+ + "```diff\n" + _diff + "\n```\n"
4242
+ )
4243
+ attempt_notes.append((attempt, _evidence))
4082
4244
  failure_note = payload
4083
4245
  _fc, _fs = parse_gate_failure_signature(payload)
4084
4246
  _ex = extract_failure_excerpt(payload)
@@ -4101,12 +4263,20 @@ def run(
4101
4263
  "attempts": attempt,
4102
4264
  "attempts_usage": attempts_usage,
4103
4265
  }))
4104
- reset_preserving_events(head_before, events_path)
4266
+ reset_preserving_events(head_before, events_path,
4267
+ untracked_before=untracked_before)
4268
+ # Persist buffered evidence (verify output + rejected
4269
+ # diff) AFTER the reset — work/ is gitignored so the
4270
+ # reset leaves it untouched, and this makes a
4271
+ # signature-repeat block diagnosable like the
4272
+ # exhausted-attempts path (#168).
4273
+ note_paths = persist_attempt_notes(
4274
+ work_dir, wu.wu_id, attempt_notes)
4105
4275
  backend.set_wu(wu, "status", "blocked_human")
4106
4276
  write_cost_to_wu(backend, wu, cum_usage)
4107
4277
  flush_events(events_path, wu_events)
4108
4278
  commit_bookkeeping(
4109
- [wu.file, events_path],
4279
+ [wu.file, events_path, *note_paths],
4110
4280
  f"chore(loop): {wu.wu_id} blocked_human "
4111
4281
  f"(spinning_signature_repeat, attempt {attempt})"
4112
4282
  f"\n\nFeature: {wu.wu_id}",
@@ -4119,7 +4289,8 @@ def run(
4119
4289
  prior_failure_signature = (_fc, _fs)
4120
4290
  flush_events(events_path, wu_events)
4121
4291
  wu_events.clear()
4122
- reset_preserving_events(head_before, events_path)
4292
+ reset_preserving_events(head_before, events_path,
4293
+ untracked_before=untracked_before)
4123
4294
  print(f" FAIL attempt {attempt}/{MAX_ATTEMPTS}")
4124
4295
  else:
4125
4296
  # for-else: ran out of attempts without break = spinning.
@@ -4138,13 +4309,8 @@ def run(
4138
4309
  o == "zero_token" for o in attempt_outcomes)
4139
4310
  reason = ("all_attempts_zero_token" if all_zero
4140
4311
  else "spinning_detected")
4141
- wu_key = wu.wu_id.replace("/", "_")
4142
- note_paths = []
4143
- for atmpt, evidence in attempt_notes:
4144
- p = work_dir / wu_key / f"attempt-{atmpt}.md"
4145
- p.parent.mkdir(parents=True, exist_ok=True)
4146
- p.write_text(evidence)
4147
- note_paths.append(p)
4312
+ note_paths = persist_attempt_notes(
4313
+ work_dir, wu.wu_id, attempt_notes)
4148
4314
  backend.set_wu(wu, "status", "blocked_human")
4149
4315
  write_cost_to_wu(backend, wu, cum_usage)
4150
4316
  wu_events.append(build_event("human_escalation", wu.wu_id, {
@@ -8,6 +8,7 @@ import importlib.resources
8
8
  import json
9
9
  import os
10
10
  import re
11
+ import sys
11
12
  from pathlib import Path
12
13
 
13
14
  from . import _miniyaml
@@ -389,9 +390,45 @@ def upgrade_specfuse(
389
390
  wire_claude(target_path)
390
391
  _write_manifest(specfuse_dir, manifest_entries)
391
392
 
393
+ _warn_unmanaged_legacy_dirs(specfuse_dir)
394
+
392
395
  return sorted(written)
393
396
 
394
397
 
398
+ def _warn_unmanaged_legacy_dirs(specfuse_dir: Path) -> None:
399
+ """Warn (stderr) when legacy scripts/ or skills/ copies exist post-upgrade.
400
+
401
+ Current scaffold data ships neither directory: scripts come from the
402
+ specfuse-loop package, skills from the specfuse@specfuse plugin. Only
403
+ pre-PyPI (init.sh-era) repos carry full copies, and `upgrade` overlays
404
+ none of them while still advancing VERSION — so a VERSION that outpaces
405
+ the vendored scripts/skills is silent drift (#165). New projects have
406
+ neither dir, so this is a no-op for them.
407
+
408
+ A warning, not a prune: reconciling those copies (see `migrate_legacy`)
409
+ is destructive and left to an explicit operator step.
410
+ """
411
+ drift: list[str] = []
412
+ for name in ("scripts", "skills"):
413
+ d = specfuse_dir / name
414
+ if d.is_dir():
415
+ n = sum(1 for p in d.rglob("*") if p.is_file())
416
+ if n:
417
+ drift.append(f" .specfuse/{name}/ ({n} file(s))")
418
+ if not drift:
419
+ return
420
+ print(
421
+ f"specfuse upgrade: WARNING — VERSION advanced to {scaffold_version()}, "
422
+ "but these directories are NOT managed by upgrade and may be stale:\n"
423
+ + "\n".join(drift)
424
+ + "\n Scripts are provided by the specfuse-loop package and skills by "
425
+ "the specfuse@specfuse plugin; these are leftover full copies from an "
426
+ "older (pre-package) install. Remove them once nothing local depends "
427
+ "on them.",
428
+ file=sys.stderr,
429
+ )
430
+
431
+
395
432
  def init(target: str | Path, *, ci_check: str | None = None) -> list[str]:
396
433
  """Bootstrap a Specfuse-enabled repo: write .specfuse/ then wire .claude/.
397
434
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.15
3
+ Version: 0.3.17
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
@@ -102,6 +102,7 @@ tests/test_lint_plan_relative_import.py
102
102
  tests/test_lint_produces_driver_helper.py
103
103
  tests/test_lint_sections.py
104
104
  tests/test_lint_task_graph_yaml_selection.py
105
+ tests/test_loop_attempt_evidence.py
105
106
  tests/test_loop_auto_archive.py
106
107
  tests/test_loop_caveman_preamble.py
107
108
  tests/test_loop_close_intermediate.py
@@ -231,6 +231,45 @@ class TestParseGateFailureSignature(unittest.TestCase):
231
231
  self.assertEqual(fc, "other")
232
232
  self.assertEqual(sig, "no_gate_marker")
233
233
 
234
+ # -- #167: non-Python gate output must not collapse to a bare fence -- #
235
+
236
+ def test_fallback_skips_bare_code_fence(self):
237
+ # A non-Python tests gate (e.g. TypeScript) matches none of the
238
+ # Python _SIG_PATTERNS and falls through. The first line after the
239
+ # marker is a Markdown fence; the signature must key off the real
240
+ # failure line beneath it, not the fence.
241
+ stdout = (
242
+ "### tests: FAIL\n"
243
+ "```\n"
244
+ " ✕ company builds a valid config (expected 3, got 4)\n"
245
+ "```\n"
246
+ )
247
+ fc, sig = loop.parse_gate_failure_signature(stdout)
248
+ self.assertEqual(fc, "tests")
249
+ self.assertNotEqual(sig, "```")
250
+ self.assertIn("company builds a valid config", sig)
251
+
252
+ def test_fallback_skips_whitespace_and_tilde_fence(self):
253
+ stdout = "### tests: FAIL\n \n~~~\n\nReal failure: assertion X\n"
254
+ fc, sig = loop.parse_gate_failure_signature(stdout)
255
+ self.assertEqual(fc, "tests")
256
+ self.assertIn("Real failure", sig)
257
+
258
+ def test_all_noninformative_returns_no_signature(self):
259
+ stdout = "### tests: FAIL\n```\n \n~~~\n"
260
+ fc, sig = loop.parse_gate_failure_signature(stdout)
261
+ self.assertEqual(fc, "tests")
262
+ self.assertEqual(sig, "no_signature")
263
+
264
+ def test_two_distinct_ts_failures_yield_distinct_signatures(self):
265
+ # The #167 false-spin: two genuinely different failures must NOT
266
+ # reduce to the same signature (both previously became "```").
267
+ a = "### tests: FAIL\n```\n ✕ builds config (expected 3, got 4)\n```\n"
268
+ b = "### tests: FAIL\n```\n ✕ parses tokens (unexpected EOF)\n```\n"
269
+ _, sig_a = loop.parse_gate_failure_signature(a)
270
+ _, sig_b = loop.parse_gate_failure_signature(b)
271
+ self.assertNotEqual(sig_a, sig_b)
272
+
234
273
 
235
274
  # --------------------------------------------------------------------------- #
236
275
  # TestExtractFailureExcerpt #
@@ -443,6 +482,49 @@ class TestDetectSpinningSignatureRepeat(unittest.TestCase):
443
482
  )
444
483
  self.assertFalse(result)
445
484
 
485
+ # -- #167: non-informative signatures must not trip the spin detector -- #
486
+
487
+ def test_false_on_bare_fence_signature(self):
488
+ # The exact #167 shape: both attempts reduced to "```". Even though
489
+ # current == prior, a bare fence carries no failure-distinguishing
490
+ # content and must not escalate.
491
+ self.assertFalse(
492
+ loop.detect_spinning_signature_repeat(
493
+ ("tests", "```"), ("tests", "```"),
494
+ )
495
+ )
496
+
497
+ def test_false_on_whitespace_signature(self):
498
+ self.assertFalse(
499
+ loop.detect_spinning_signature_repeat(
500
+ ("tests", " "), ("tests", " "),
501
+ )
502
+ )
503
+
504
+ def test_false_on_pure_ansi_signature(self):
505
+ ansi = "\x1b[31m\x1b[0m"
506
+ self.assertFalse(
507
+ loop.detect_spinning_signature_repeat(
508
+ ("tests", ansi), ("tests", ansi),
509
+ )
510
+ )
511
+
512
+ def test_false_on_no_signature_sentinel(self):
513
+ self.assertFalse(
514
+ loop.detect_spinning_signature_repeat(
515
+ ("tests", "no_signature"), ("tests", "no_signature"),
516
+ )
517
+ )
518
+
519
+ def test_still_true_on_real_repeated_signature(self):
520
+ # Guard against over-correction: a genuine repeat still escalates.
521
+ self.assertTrue(
522
+ loop.detect_spinning_signature_repeat(
523
+ ("tests", "builds config (expected 3, got 4)"),
524
+ ("tests", "builds config (expected 3, got 4)"),
525
+ )
526
+ )
527
+
446
528
 
447
529
  # --------------------------------------------------------------------------- #
448
530
  # TestSummarizeAttemptFailureClasses #
@@ -0,0 +1,116 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Unit tests for #168 — persist rejected-attempt evidence before git reset.
6
+
7
+ Two helpers keep a blocked WU diagnosable:
8
+
9
+ - `capture_working_tree_diff(head_before)` folds the rejected diff into a
10
+ failing attempt's note BEFORE the per-attempt `git reset` discards it.
11
+ - `persist_attempt_notes(work_dir, wu_id, notes)` writes the buffered
12
+ evidence to `work/<wu>/attempt-N.md`. It is now called on BOTH escalation
13
+ paths (early spinning_signature_repeat and exhausted-attempts); previously
14
+ only the latter wrote notes, so a signature-repeat block lost all evidence.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import tempfile
22
+ import unittest
23
+ from contextlib import contextmanager
24
+ from pathlib import Path
25
+
26
+ from tests._loop_loader import load_loop
27
+
28
+ loop = load_loop()
29
+
30
+
31
+ @contextmanager
32
+ def _git_repo_no_sign():
33
+ """Minimal git repo with signing disabled (operator config-independent)."""
34
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
35
+ root = Path(tmp)
36
+ run = lambda *a: subprocess.run( # noqa: E731
37
+ ["git", "-C", str(root), *a], check=True, capture_output=True,
38
+ )
39
+ run("init", "-q", "-b", "main")
40
+ run("config", "commit.gpgSign", "false")
41
+ run("config", "gc.auto", "0")
42
+ run("config", "user.email", "test@example.com")
43
+ run("config", "user.name", "Test")
44
+ (root / "README.md").write_text("# fixture\n")
45
+ run("add", ".")
46
+ run("commit", "-q", "-m", "init")
47
+ run("rev-parse", "HEAD")
48
+ prev_cwd = os.getcwd()
49
+ try:
50
+ os.chdir(root)
51
+ yield root
52
+ finally:
53
+ os.chdir(prev_cwd)
54
+
55
+
56
+ class TestCaptureWorkingTreeDiff(unittest.TestCase):
57
+
58
+ def test_captures_tracked_modification(self):
59
+ with _git_repo_no_sign() as root:
60
+ head = loop.git("rev-parse", "HEAD")
61
+ (root / "README.md").write_text("# fixture\nnew line\n")
62
+ diff = loop.capture_working_tree_diff(head)
63
+ self.assertIn("new line", diff)
64
+ self.assertIn("README.md", diff)
65
+
66
+ def test_empty_on_clean_tree(self):
67
+ with _git_repo_no_sign():
68
+ head = loop.git("rev-parse", "HEAD")
69
+ self.assertEqual(loop.capture_working_tree_diff(head), "")
70
+
71
+ def test_truncates_large_diff(self):
72
+ with _git_repo_no_sign() as root:
73
+ head = loop.git("rev-parse", "HEAD")
74
+ (root / "big.txt").write_text("x\n" * 100000)
75
+ subprocess.run(["git", "-C", str(root), "add", "big.txt"],
76
+ check=True, capture_output=True)
77
+ diff = loop.capture_working_tree_diff(head, max_chars=2000)
78
+ self.assertLessEqual(len(diff), 2000 + 64)
79
+ self.assertIn("truncated", diff)
80
+
81
+ def test_empty_on_bad_ref(self):
82
+ with _git_repo_no_sign():
83
+ # A non-existent ref makes git diff exit non-zero → "" (best-effort).
84
+ self.assertEqual(
85
+ loop.capture_working_tree_diff("deadbeefdeadbeef"), "")
86
+
87
+
88
+ class TestPersistAttemptNotes(unittest.TestCase):
89
+
90
+ def test_writes_one_file_per_attempt(self):
91
+ with tempfile.TemporaryDirectory() as tmp:
92
+ work = Path(tmp) / "work"
93
+ notes = [(1, "first failure"), (2, "second failure")]
94
+ paths = loop.persist_attempt_notes(work, "FEAT-2026-0016/T03", notes)
95
+ self.assertEqual(len(paths), 2)
96
+ self.assertTrue((work / "FEAT-2026-0016_T03" / "attempt-1.md").exists())
97
+ self.assertEqual(
98
+ (work / "FEAT-2026-0016_T03" / "attempt-2.md").read_text(),
99
+ "second failure")
100
+
101
+ def test_wu_id_slashes_flattened(self):
102
+ with tempfile.TemporaryDirectory() as tmp:
103
+ work = Path(tmp) / "work"
104
+ paths = loop.persist_attempt_notes(work, "A/B/C", [(1, "x")])
105
+ self.assertIn("A_B_C", str(paths[0]))
106
+
107
+ def test_empty_notes_writes_nothing(self):
108
+ with tempfile.TemporaryDirectory() as tmp:
109
+ work = Path(tmp) / "work"
110
+ paths = loop.persist_attempt_notes(work, "X/T01", [])
111
+ self.assertEqual(paths, [])
112
+ self.assertFalse(work.exists())
113
+
114
+
115
+ if __name__ == "__main__":
116
+ unittest.main()