specfuse-loop 0.3.16__tar.gz → 0.3.18__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.16/specfuse_loop.egg-info → specfuse_loop-0.3.18}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/pyproject.toml +1 -1
  3. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/_miniyaml.py +14 -7
  4. specfuse_loop-0.3.18/specfuse/loop/data/VERSION +1 -0
  5. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/verification.yml.example +12 -0
  6. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/lint_plan.py +87 -0
  7. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/loop.py +194 -18
  8. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/scaffold.py +37 -0
  9. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18/specfuse_loop.egg-info}/PKG-INFO +1 -1
  10. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/SOURCES.txt +2 -0
  11. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_attempt_outcome_emission.py +125 -0
  12. specfuse_loop-0.3.18/tests/test_lint_verification_reference.py +177 -0
  13. specfuse_loop-0.3.18/tests/test_loop_attempt_evidence.py +116 -0
  14. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_orchestration.py +35 -0
  15. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_miniyaml_negative.py +10 -7
  16. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_upgrade.py +66 -0
  17. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_untracked_file_absorption.py +5 -0
  18. specfuse_loop-0.3.16/specfuse/loop/data/VERSION +0 -1
  19. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/LICENSE +0 -0
  20. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/NOTICE +0 -0
  21. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/README.md +0 -0
  22. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/setup.cfg +0 -0
  23. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/__init__.py +0 -0
  24. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/_filelock.py +0 -0
  25. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/adopt_feature.py +0 -0
  26. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  27. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  28. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  29. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/getting-started.md +0 -0
  30. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/methodology.md +0 -0
  31. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/skills.md +0 -0
  32. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/gitignore.snippet +0 -0
  33. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/roadmap.template.md +0 -0
  34. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  35. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/never-touch.md +0 -0
  36. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/result-contract.md +0 -0
  37. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  38. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  39. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/event.schema.json +0 -0
  40. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  41. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  42. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  43. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  44. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/GATE.template.md +0 -0
  45. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  46. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/WU.template.md +0 -0
  47. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/gate_eval.py +0 -0
  48. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/gh_backend.py +0 -0
  49. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/gh_features.py +0 -0
  50. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/validate_event.py +0 -0
  51. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  52. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/entry_points.txt +0 -0
  53. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/requires.txt +0 -0
  54. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/top_level.txt +0 -0
  55. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_adopt_feature.py +0 -0
  56. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  57. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autoclose_deferral_visibility.py +0 -0
  58. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync.py +0 -0
  59. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync_consent.py +0 -0
  60. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync_firstrun.py +0 -0
  61. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync_plugin.py +0 -0
  62. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_backend.py +0 -0
  63. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bash_routing.py +0 -0
  64. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  65. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  66. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bookkeeping_no_verify.py +0 -0
  67. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_claude_resolution.py +0 -0
  68. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_close_wu_self_reference.py +0 -0
  69. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_closing_deliverable_guard.py +0 -0
  70. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_console_encoding.py +0 -0
  71. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_cost_tracking.py +0 -0
  72. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_degraded_oracle.py +0 -0
  73. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_deliverable_presence_gate.py +0 -0
  74. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_doctor.py +0 -0
  75. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_driver_integration.py +0 -0
  76. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_driver_lock.py +0 -0
  77. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_duration_tracking.py +0 -0
  78. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_empty_files_escalation.py +0 -0
  79. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch.py +0 -0
  80. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch_base.py +0 -0
  81. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_events_redaction.py +0 -0
  82. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_extra_gates.py +0 -0
  83. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_filelock_portable.py +0 -0
  84. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_force_full_close.py +0 -0
  85. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval.py +0 -0
  86. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval_calibration.py +0 -0
  87. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  88. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval_terminal_wiring.py +0 -0
  89. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gh_backend.py +0 -0
  90. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gh_features.py +0 -0
  91. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_git_env_isolation.py +0 -0
  92. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_hashed_denylist.py +0 -0
  93. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_hashed_denylist_ci.py +0 -0
  94. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_init_integration.py +0 -0
  95. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_interpreter_normalization.py +0 -0
  96. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_leak_findings_redaction.py +0 -0
  97. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_leak_scan.py +0 -0
  98. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_leak_scan_content.py +0 -0
  99. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_learnings_query.py +0 -0
  100. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  101. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lifecycle_integration.py +0 -0
  102. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_bare_produces_path.py +0 -0
  103. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_close_intermediate.py +0 -0
  104. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_close_wu.py +0 -0
  105. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id.py +0 -0
  106. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  107. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_oracle_env.py +0 -0
  108. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_plan_errors.py +0 -0
  109. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_plan_next_draft.py +0 -0
  110. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_plan_relative_import.py +0 -0
  111. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_produces_driver_helper.py +0 -0
  112. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_sections.py +0 -0
  113. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  114. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_auto_archive.py +0 -0
  115. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_caveman_preamble.py +0 -0
  116. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_close_intermediate.py +0 -0
  117. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_defaults_by_type.py +0 -0
  118. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_effort.py +0 -0
  119. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_failure_note_cap.py +0 -0
  120. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_files_changed_guard.py +0 -0
  121. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_gate_budget.py +0 -0
  122. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_model_alias.py +0 -0
  123. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_post_pass_invariant.py +0 -0
  124. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_reset_preserving_events.py +0 -0
  125. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_smoke_runner.py +0 -0
  126. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_unsandboxed.py +0 -0
  127. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_zero_token_guard.py +0 -0
  128. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_migrate_legacy.py +0 -0
  129. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_miniyaml_equivalence.py +0 -0
  130. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_planned_cost_lint.py +0 -0
  131. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_prepare_feature.py +0 -0
  132. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_prepare_scaffold_sync.py +0 -0
  133. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_produces_field.py +0 -0
  134. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_redaction_windows_home.py +0 -0
  135. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_resolve_base.py +0 -0
  136. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_result_block.py +0 -0
  137. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_roadmap_add_skill.py +0 -0
  138. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_roadmap_archive_skill.py +0 -0
  139. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_roadmap_row_parser.py +0 -0
  140. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_data_in_sync.py +0 -0
  141. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_doc_hygiene.py +0 -0
  142. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_docs.py +0 -0
  143. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_init.py +0 -0
  144. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_manifest.py +0 -0
  145. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_resources.py +0 -0
  146. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_seed_sanity.py +0 -0
  147. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_wiring.py +0 -0
  148. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_skills_vendored_in_sync.py +0 -0
  149. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_squash_commit_hook_crash.py +0 -0
  150. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_template_closing_shapes.py +0 -0
  151. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_terminal_flip_ownership.py +0 -0
  152. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_terminal_flips.py +0 -0
  153. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_timeout_kill_portable.py +0 -0
  154. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_untracked_feature_folder.py +0 -0
  155. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_windows_gate_exec.py +0 -0
  163. {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/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.16
3
+ Version: 0.3.18
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.16"
7
+ version = "0.3.18"
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"
@@ -53,7 +53,9 @@ on a missing gate set — silent misparses are worse than crashes.
53
53
  fields that the driver writes to WU frontmatter.
54
54
  - **booleans** — exact lowercase `true` / `false` only. `True`, `yes`,
55
55
  `on`, etc. are errors.
56
- - **empty value** — a key followed by `:` with nothing after is `None`.
56
+ - **empty value / explicit null** — a key followed by `:` with nothing
57
+ after is `None`; so are the explicit YAML null spellings `null`,
58
+ `Null`, `NULL`, and `~` (matching yaml.safe_load).
57
59
  * **Inline (flow) lists** — `[]`, `[a]`, `[a, b]`. Items are scalars only;
58
60
  nested brackets are an error. Real usage is correlation-ID lists.
59
61
  * **Comments** — `#` starting a line (after optional whitespace), or
@@ -73,8 +75,9 @@ on a missing gate set — silent misparses are worse than crashes.
73
75
  * Mixed / inconsistent indentation.
74
76
  * Nested brackets inside a flow list.
75
77
  * Escape sequences other than `\\` and `\"` in double-quoted strings.
76
- * Boolean spellings other than exact lowercase `true` / `false`.
77
- * Explicit null markers (`null`, `Null`, `NULL`, `~`).
78
+ * Boolean spellings other than exact lowercase `true` / `false` (the YAML
79
+ 1.1 "Norway problem" `yes`/`no`/`on`/`off`/`True` stays rejected on
80
+ purpose; only `null` is accepted as a non-lowercase-keyword idiom).
78
81
  * Integers with sign, leading zeros, floats, hex, or underscores.
79
82
 
80
83
  A file using any of those raises `MiniYAMLError` with a line number and a
@@ -108,7 +111,7 @@ _FORBIDDEN_BOOLS = {
108
111
  "yes", "Yes", "YES", "no", "No", "NO",
109
112
  "on", "On", "ON", "off", "Off", "OFF",
110
113
  }
111
- _FORBIDDEN_NULLS = {"null", "Null", "NULL", "~"}
114
+ _NULL_SCALARS = {"null", "Null", "NULL", "~"}
112
115
 
113
116
 
114
117
  # --------------------------------------------------------------------------- #
@@ -363,9 +366,13 @@ def _parse_scalar(text: str, lineno: int) -> Any:
363
366
  # forgiving result-block parse path on real agent output.
364
367
  raise MiniYAMLError(
365
368
  f"line {lineno}: flow mappings unsupported — use a block mapping")
366
- if text in _FORBIDDEN_NULLS:
367
- raise MiniYAMLError(
368
- f"line {lineno}: explicit null marker unsupported leave value empty")
369
+ if text in _NULL_SCALARS:
370
+ # Explicit YAML null spellings map to None, same as an empty value.
371
+ # Accepting them (rather than failing loud) removes a foot-gun for
372
+ # hand-authored frontmatter and for anything generated by a real YAML
373
+ # library, which emits `null` for None — and matches yaml.safe_load,
374
+ # tightening the equivalence guarantee (#177).
375
+ return None
369
376
  if text in _FORBIDDEN_BOOLS:
370
377
  raise MiniYAMLError(
371
378
  f"line {lineno}: only lowercase `true`/`false` accepted as "
@@ -0,0 +1 @@
1
+ 0.3.18
@@ -28,6 +28,15 @@
28
28
  #
29
29
  # cost_tracking: true
30
30
 
31
+ # PER-GATE `wu_must_reference: true` (optional, default false). Flag a gate
32
+ # whose fix is MECHANICAL over a work unit's own output — a formatter, an
33
+ # auto-fixable linter (`spotless:check`, `black --check`, `gofmt -l`,
34
+ # `prettier --check`). The plan linter (specfuse-lint) then WARNs when an
35
+ # implementation WU's Verification section names none of that gate's command —
36
+ # catching, at authoring time and zero model cost, the case where a WU forgets
37
+ # a mechanical gate and the driver spins retries on the identical failure until
38
+ # the attempt budget is exhausted. Leave it off for project-global gates
39
+ # (leak-scan, hygiene checks) that an individual WU has no reason to name.
31
40
  code:
32
41
  - name: tests
33
42
  command: "pytest -q"
@@ -37,6 +46,9 @@ code:
37
46
  command: "python3 -W error -c 'pass'" # replace with your build's -Werror equivalent
38
47
  - name: lint
39
48
  command: "ruff check ."
49
+ - name: format
50
+ command: "black --check ." # deterministic formatter: fix with `black .`
51
+ wu_must_reference: true # WUs must name it (see note above)
40
52
  - name: security
41
53
  command: "bandit -r src -ll" # or your OWASP-aligned scanner
42
54
 
@@ -138,6 +138,69 @@ def _slice_section(body: str, section_name: str) -> str:
138
138
  return after[:em.start()] if em else after
139
139
 
140
140
 
141
+ # Interpreters / super-generic words that don't distinguish one gate command
142
+ # from another — excluded from a command's signature tokens (#176).
143
+ _GATE_TOKEN_STOP = {
144
+ "python", "python3", "bash", "sh", "run", "exec", "discover",
145
+ "report", "source", "true", "false",
146
+ }
147
+ _GATE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.:+-]{2,}")
148
+ # Verification prose that delegates to the whole gate set — treat as
149
+ # referencing every gate so a legitimate "all gates pass" phrasing is not
150
+ # flagged.
151
+ _UMBRELLA_GATES_RE = re.compile(r"(?i)\b(all|every|entire|full|the)\b[^.\n]{0,24}\bgate")
152
+
153
+
154
+ def _must_reference_gate_signatures(feature_dir: Path) -> list[tuple[str, set[str]]]:
155
+ """Return [(gate_name, signature_tokens)] for OPT-IN must-reference gates (#176).
156
+
157
+ Reads ``<project>/.specfuse/verification.yml`` (``feature_dir`` is
158
+ ``.../.specfuse/features/<name>``) and includes only `code` gates that set
159
+ ``wu_must_reference: true``. This is deliberately opt-in: warning on every
160
+ declared gate is noise on real multi-gate projects (leak-scan, hygiene
161
+ bats, etc. that an individual WU has no business naming). A project flags
162
+ the gates whose fix is mechanical over a WU's own output — formatters,
163
+ auto-fixable linters — the class #175 shows can burn the whole attempt
164
+ budget when a WU forgets them. Empty list (the check no-ops) when the file
165
+ is absent, unparseable, or no gate opts in. Each gate's token set is the
166
+ distinctive words of its command plus the gate name, lowercased.
167
+ """
168
+ vpath = feature_dir.parent.parent / "verification.yml"
169
+ if not vpath.is_file():
170
+ return []
171
+ try:
172
+ cfg = _miniyaml.parse(vpath.read_text()) or {}
173
+ except _miniyaml.MiniYAMLError:
174
+ return []
175
+ out: list[tuple[str, set[str]]] = []
176
+ for gate in cfg.get("code") or []:
177
+ if not isinstance(gate, dict) or gate.get("wu_must_reference") is not True:
178
+ continue
179
+ name = str(gate.get("name") or "").strip()
180
+ command = str(gate.get("command") or "")
181
+ tokens = {t.lower() for t in _GATE_TOKEN_RE.findall(command)
182
+ if t.lower() not in _GATE_TOKEN_STOP}
183
+ if name:
184
+ tokens.add(name.lower())
185
+ if tokens:
186
+ out.append((name or command[:40], tokens))
187
+ return out
188
+
189
+
190
+ def _unreferenced_code_gates(
191
+ verification_text: str, signatures: list[tuple[str, set[str]]],
192
+ ) -> list[str]:
193
+ """Names of code gates whose tokens *verification_text* references none of.
194
+
195
+ An umbrella "all gates" phrasing short-circuits to [] (author delegated).
196
+ """
197
+ if _UMBRELLA_GATES_RE.search(verification_text):
198
+ return []
199
+ low = verification_text.lower()
200
+ return [name for name, tokens in signatures
201
+ if not any(tok in low for tok in tokens)]
202
+
203
+
141
204
  def detect_oracle_verbs(ac_section_text: str) -> list[str]:
142
205
  """Return matched oracle-verb strings found in the AC section text."""
143
206
  found = []
@@ -249,6 +312,11 @@ def lint(feature_dir: Path) -> list[str]:
249
312
  if missing:
250
313
  errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
251
314
 
315
+ # Opt-in must-reference gate signatures, loaded once for the
316
+ # Verification-reference WARN below (#176). Empty unless the project flags
317
+ # gates with `wu_must_reference: true`.
318
+ must_ref_gate_sigs = _must_reference_gate_signatures(feature_dir)
319
+
252
320
  if "base" in fm:
253
321
  base_val = fm["base"]
254
322
  feature_id_val = fm.get("feature_id", feature_dir.name)
@@ -400,6 +468,25 @@ def lint(feature_dir: Path) -> list[str]:
400
468
  f"driver. See authoring-work-units §9 + FEAT-2026-0017."
401
469
  )
402
470
 
471
+ # Verification ↔ must-reference gate WARN (#176). For gates a
472
+ # project has flagged `wu_must_reference: true` — those whose fix is
473
+ # mechanical over a WU's own output (formatters, auto-fixable
474
+ # linters) — the driver enforces them regardless of what a WU's
475
+ # Verification names. A session not told to run one writes code that
476
+ # fails it and, per #175, can burn the whole attempt budget on the
477
+ # mechanical failure. Flag at lint time, at zero model cost. Opt-in,
478
+ # so no noise on projects that don't flag any gate.
479
+ if wu_type_val == "implementation" and must_ref_gate_sigs:
480
+ verif_text = _slice_section(wbody, "Verification")
481
+ unref = _unreferenced_code_gates(verif_text, must_ref_gate_sigs)
482
+ if unref:
483
+ print(
484
+ f"WARN: {wfile}: Verification references none of the "
485
+ f"must-reference gate(s): {', '.join(unref)}. The driver "
486
+ f"runs them regardless; name each so the session knows to "
487
+ f"satisfy it before finishing (#176)."
488
+ )
489
+
403
490
  # Deliverable-presence declaration WARN (FEAT-2026-0022/T01).
404
491
  # Advisory: an implementation WU should declare the file path(s) it
405
492
  # is contracted to yield via `produces:`, which T02's presence gate
@@ -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.16"
66
+ DRIVER_VERSION = "0.3.18"
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
@@ -190,14 +190,27 @@ FM = re.compile(r"^---\s*$")
190
190
 
191
191
 
192
192
  def read_frontmatter(path: Path) -> tuple[dict, str]:
193
- """Return (frontmatter_dict, body_text)."""
193
+ """Return (frontmatter_dict, body_text).
194
+
195
+ A parse failure is re-raised with the file path attached and the line
196
+ clarified as frontmatter-relative (add 1 for the file line, since the
197
+ opening `---` is line 1). Without this a `_miniyaml.MiniYAMLError` carries
198
+ only a block-relative line number, so locating the offending file across
199
+ many feature folders meant grepping by hand (#177).
200
+ """
194
201
  lines = path.read_text().splitlines()
195
202
  if not lines or not FM.match(lines[0]):
196
203
  return {}, path.read_text()
197
204
  j = 1
198
205
  while j < len(lines) and not FM.match(lines[j]):
199
206
  j += 1
200
- fm = _miniyaml.parse("\n".join(lines[1:j])) or {}
207
+ try:
208
+ fm = _miniyaml.parse("\n".join(lines[1:j])) or {}
209
+ except _miniyaml.MiniYAMLError as exc:
210
+ raise _miniyaml.MiniYAMLError(
211
+ f"{path}: {exc} (line is relative to the frontmatter block; "
212
+ f"add 1 for the file line)"
213
+ ) from exc
201
214
  body = "\n".join(lines[j + 1 :])
202
215
  return fm, body
203
216
 
@@ -261,7 +274,16 @@ def find_feature(arg: str | None) -> Path:
261
274
  for d in sorted(FEATURES_DIR.glob("*/")):
262
275
  plan = d / "PLAN.md"
263
276
  if plan.exists():
264
- fm, _ = read_frontmatter(plan)
277
+ try:
278
+ fm, _ = read_frontmatter(plan)
279
+ except _miniyaml.MiniYAMLError as exc:
280
+ # One feature's malformed frontmatter must not block dispatch of
281
+ # every OTHER feature. Feature lookup is the earliest and least
282
+ # relevant failure point; skip the bad candidate with a warning
283
+ # rather than aborting the whole scan (#177).
284
+ print(f"warning: skipping {d.name} — unparseable PLAN.md "
285
+ f"frontmatter: {exc}", file=sys.stderr)
286
+ continue
265
287
  if fm.get("status") == "active":
266
288
  actives.append(d)
267
289
  elif fm.get("status") == "done":
@@ -542,6 +564,36 @@ def detect_degraded_oracle(stdout: str) -> str | None:
542
564
  return None
543
565
 
544
566
 
567
+ # A failure signature carries no distinguishing content when it is a bare
568
+ # code/markdown fence, whitespace-only, or pure ANSI. Non-Python gate output
569
+ # (e.g. a TypeScript `tests` gate) matches none of the Python-specific
570
+ # _SIG_PATTERNS below and used to fall through to the first line after the
571
+ # marker — often a ``` fence — collapsing genuinely different failures to one
572
+ # value and false-firing spinning_signature_repeat (#167).
573
+ _FENCE_RE = re.compile(r"^[`~]{3,}[\w.+-]*$")
574
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
575
+ # Sentinel returned when every line after the FAIL marker is non-informative.
576
+ # Distinct from `no_gate_marker` (a marker WAS found) and treated as
577
+ # non-informative by `_is_noninformative_signature`.
578
+ NO_SIGNATURE = "no_signature"
579
+
580
+
581
+ def _is_noninformative_signature(sig: "str | None") -> bool:
582
+ """True when *sig* cannot distinguish one failure from another.
583
+
584
+ Covers None, whitespace-only, bare fences (``` / ~~~, optionally with a
585
+ language tag), pure-ANSI strings, and the NO_SIGNATURE sentinel. The spin
586
+ detector and the extraction fallback both consult this so a content-free
587
+ signature neither becomes a WU's identity nor trips escalation (#167).
588
+ """
589
+ if sig is None or sig == NO_SIGNATURE:
590
+ return True
591
+ stripped = _ANSI_RE.sub("", sig).strip()
592
+ if not stripped:
593
+ return True
594
+ return bool(_FENCE_RE.match(stripped))
595
+
596
+
545
597
  def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
546
598
  """Extract (failure_class, failure_signature) from gate runner stdout.
547
599
 
@@ -577,9 +629,12 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
577
629
  return failure_class, sig if sig else "unknown"
578
630
  for line in after_lines:
579
631
  stripped = line.strip()
580
- if stripped:
581
- return failure_class, stripped[:100]
582
- return failure_class, "unknown"
632
+ # Skip non-informative lines (bare fences, whitespace, pure ANSI):
633
+ # keying the signature off one would collapse distinct failures (#167).
634
+ if _is_noninformative_signature(stripped):
635
+ continue
636
+ return failure_class, stripped[:100]
637
+ return failure_class, NO_SIGNATURE
583
638
 
584
639
 
585
640
  def detect_spinning_signature_repeat(
@@ -592,6 +647,9 @@ def detect_spinning_signature_repeat(
592
647
  Returns False when either element of current is None.
593
648
  Returns False when current or prior is the no_gate_marker sentinel to
594
649
  avoid false-positive halts on parser-opaque failures (AC4).
650
+ Returns False when either signature is non-informative — a bare fence,
651
+ whitespace, pure ANSI, or the NO_SIGNATURE sentinel — since such values
652
+ collapse distinct failures and would false-fire the spin halt (#167).
595
653
  """
596
654
  _SENTINEL = ("other", "no_gate_marker")
597
655
  if prior is None:
@@ -600,6 +658,9 @@ def detect_spinning_signature_repeat(
600
658
  return False
601
659
  if current == _SENTINEL or prior == _SENTINEL:
602
660
  return False
661
+ if (_is_noninformative_signature(current[1])
662
+ or _is_noninformative_signature(prior[1])):
663
+ return False
603
664
  return current == prior
604
665
 
605
666
 
@@ -616,7 +677,56 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
616
677
  encoded = text.encode("utf-8")
617
678
  if len(encoded) <= max_chars:
618
679
  return text
619
- return encoded[-max_chars:].decode("utf-8", errors="ignore")
680
+ # Head+tail, not tail-only (#175): build-tool output puts the actionable
681
+ # part (the list of offending files) up top and boilerplate (help URLs,
682
+ # stack-trace hints) at the bottom. A tail-only slice reliably keeps the
683
+ # noise; splitting the budget preserves both ends.
684
+ marker = "\n...\n"
685
+ budget = max_chars - len(marker.encode("utf-8"))
686
+ if budget <= 0:
687
+ return encoded[-max_chars:].decode("utf-8", errors="ignore")
688
+ head_bytes = budget // 2
689
+ tail_bytes = budget - head_bytes
690
+ head = encoded[:head_bytes].decode("utf-8", errors="ignore")
691
+ tail = encoded[-tail_bytes:].decode("utf-8", errors="ignore")
692
+ return head + marker + tail
693
+
694
+
695
+ # Forward-looking retry directives keyed by failure_class (#175 fix 1). The
696
+ # gate output replayed into a retry describes files the per-attempt reset
697
+ # already discarded, and reads as stale advice about a prior attempt. These
698
+ # reframe it as a standing requirement for the fresh attempt and name the
699
+ # mechanical remedy per class — no per-tool knowledge.
700
+ _RETRY_CLASS_HINT: dict[str, str] = {
701
+ "lint": "This was a lint/format gate — its fix is mechanical: run the "
702
+ "formatter / linter's fix command over your changes and re-run the "
703
+ "gate before declaring done, rather than only rewriting the code.",
704
+ "tests": "Run the failing tests and make them pass before declaring done.",
705
+ "coverage": "Add tests covering the new or changed code to clear the "
706
+ "coverage gate before declaring done.",
707
+ "security": "Resolve the flagged security findings (or add a narrowly "
708
+ "scoped, justified suppression) before declaring done.",
709
+ }
710
+
711
+
712
+ def synthesize_retry_directive(failure_class: "str | None") -> str:
713
+ """Return a forward-looking retry instruction for *failure_class* (#175).
714
+
715
+ Prepended to the failure note fed into the next attempt. The note itself
716
+ is raw gate output about files the reset discarded; without this lead a
717
+ fresh session reads it as stale advice and reproduces the same omission
718
+ (e.g. never running the formatter), failing identically until attempts
719
+ exhaust. The lead reframes the output as a standing requirement and the
720
+ per-class hint names the remedy generically.
721
+ """
722
+ lead = ("The previous attempt's work was DISCARDED before this retry, so "
723
+ "the gate output below refers to files that no longer exist. Do "
724
+ "not read it as stale advice about a past attempt — treat it as a "
725
+ "standing requirement for THIS fresh attempt. ")
726
+ hint = _RETRY_CLASS_HINT.get(
727
+ failure_class or "",
728
+ "Satisfy every declared gate before declaring done.")
729
+ return lead + hint
620
730
 
621
731
 
622
732
  def emit_attempt_outcome(
@@ -1498,6 +1608,51 @@ def reset_preserving_events(
1498
1608
  _clean_attempt_untracked(untracked_before, events_path)
1499
1609
 
1500
1610
 
1611
+ def capture_working_tree_diff(head_before: str, max_chars: int = 20000) -> str:
1612
+ """Return `git diff <head_before>` (working tree vs pre-attempt base), capped.
1613
+
1614
+ Folded into a failing attempt's evidence note BEFORE the per-attempt
1615
+ `git reset --hard` discards the rejected work, so a blocked WU stays
1616
+ diagnosable — a human (or /gate-status) can see the diff that was rolled
1617
+ back, not just a truncated failure excerpt (#168).
1618
+
1619
+ Best-effort: returns "" on any git error or a clean tree. Truncated to
1620
+ max_chars so a large refactor's diff cannot bloat the committed note.
1621
+ """
1622
+ try:
1623
+ out = subprocess.run(
1624
+ ["git", "diff", head_before],
1625
+ capture_output=True, text=True, check=True,
1626
+ ).stdout
1627
+ except (subprocess.CalledProcessError, OSError):
1628
+ return ""
1629
+ if len(out) > max_chars:
1630
+ out = out[:max_chars] + "\n… (diff truncated)\n"
1631
+ return out
1632
+
1633
+
1634
+ def persist_attempt_notes(
1635
+ work_dir: Path, wu_id: str, attempt_notes: "list[tuple[int, str]]",
1636
+ ) -> "list[Path]":
1637
+ """Write buffered per-attempt evidence to work/<wu>/attempt-N.md.
1638
+
1639
+ Extracted so both escalation paths persist evidence the same way: the
1640
+ exhausted-attempts `for-else` and the early `spinning_signature_repeat`
1641
+ halt. Previously only the former wrote notes, so a signature-repeat block
1642
+ left nothing on disk to diagnose (#168). Returns the written paths for
1643
+ inclusion in the escalation commit. `work/` is gitignored, so the explicit
1644
+ `git add` in commit_bookkeeping is what tracks these.
1645
+ """
1646
+ wu_key = wu_id.replace("/", "_")
1647
+ paths: list[Path] = []
1648
+ for atmpt, evidence in attempt_notes:
1649
+ p = work_dir / wu_key / f"attempt-{atmpt}.md"
1650
+ p.parent.mkdir(parents=True, exist_ok=True)
1651
+ p.write_text(evidence)
1652
+ paths.append(p)
1653
+ return paths
1654
+
1655
+
1501
1656
  class SquashCommitError(RuntimeError):
1502
1657
  """Raised when squash_commit's `git commit` is rejected (non-zero exit).
1503
1658
 
@@ -4144,10 +4299,29 @@ def run(
4144
4299
  # Per-attempt notes are buffered (not written to disk) so they
4145
4300
  # ride with the spinning-escalation commit if we exhaust
4146
4301
  # attempts; on eventual PASS they're discarded as scratch.
4147
- attempt_notes.append((attempt, payload))
4148
- failure_note = payload
4302
+ # Fold the rejected working-tree diff into the buffered
4303
+ # evidence NOW, before the reset below discards it (#168).
4304
+ # Signature/excerpt parsing stays on the raw payload.
4305
+ _diff = capture_working_tree_diff(head_before)
4306
+ _evidence = payload
4307
+ if _diff:
4308
+ _evidence = (
4309
+ payload
4310
+ + "\n\n## Rejected working-tree diff "
4311
+ "(discarded by git reset on block)\n\n"
4312
+ + "```diff\n" + _diff + "\n```\n"
4313
+ )
4314
+ attempt_notes.append((attempt, _evidence))
4149
4315
  _fc, _fs = parse_gate_failure_signature(payload)
4150
4316
  _ex = extract_failure_excerpt(payload)
4317
+ # Prepend a forward-looking directive so the retry reads the
4318
+ # gate output as a standing requirement, not stale advice
4319
+ # about the discarded attempt (#175 fix 1).
4320
+ failure_note = (
4321
+ synthesize_retry_directive(_fc)
4322
+ + "\n\n## Gate output from the discarded attempt\n\n"
4323
+ + payload
4324
+ )
4151
4325
  wu_events.append(emit_attempt_outcome(
4152
4326
  wu, attempt, "failed",
4153
4327
  attempts_usage[-1],
@@ -4169,11 +4343,18 @@ def run(
4169
4343
  }))
4170
4344
  reset_preserving_events(head_before, events_path,
4171
4345
  untracked_before=untracked_before)
4346
+ # Persist buffered evidence (verify output + rejected
4347
+ # diff) AFTER the reset — work/ is gitignored so the
4348
+ # reset leaves it untouched, and this makes a
4349
+ # signature-repeat block diagnosable like the
4350
+ # exhausted-attempts path (#168).
4351
+ note_paths = persist_attempt_notes(
4352
+ work_dir, wu.wu_id, attempt_notes)
4172
4353
  backend.set_wu(wu, "status", "blocked_human")
4173
4354
  write_cost_to_wu(backend, wu, cum_usage)
4174
4355
  flush_events(events_path, wu_events)
4175
4356
  commit_bookkeeping(
4176
- [wu.file, events_path],
4357
+ [wu.file, events_path, *note_paths],
4177
4358
  f"chore(loop): {wu.wu_id} blocked_human "
4178
4359
  f"(spinning_signature_repeat, attempt {attempt})"
4179
4360
  f"\n\nFeature: {wu.wu_id}",
@@ -4206,13 +4387,8 @@ def run(
4206
4387
  o == "zero_token" for o in attempt_outcomes)
4207
4388
  reason = ("all_attempts_zero_token" if all_zero
4208
4389
  else "spinning_detected")
4209
- wu_key = wu.wu_id.replace("/", "_")
4210
- note_paths = []
4211
- for atmpt, evidence in attempt_notes:
4212
- p = work_dir / wu_key / f"attempt-{atmpt}.md"
4213
- p.parent.mkdir(parents=True, exist_ok=True)
4214
- p.write_text(evidence)
4215
- note_paths.append(p)
4390
+ note_paths = persist_attempt_notes(
4391
+ work_dir, wu.wu_id, attempt_notes)
4216
4392
  backend.set_wu(wu, "status", "blocked_human")
4217
4393
  write_cost_to_wu(backend, wu, cum_usage)
4218
4394
  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.16
3
+ Version: 0.3.18
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,8 @@ 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_lint_verification_reference.py
106
+ tests/test_loop_attempt_evidence.py
105
107
  tests/test_loop_auto_archive.py
106
108
  tests/test_loop_caveman_preamble.py
107
109
  tests/test_loop_close_intermediate.py