specfuse-loop 0.3.17__tar.gz → 0.3.19__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.17/specfuse_loop.egg-info → specfuse_loop-0.3.19}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/pyproject.toml +1 -1
  3. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/_miniyaml.py +14 -7
  4. specfuse_loop-0.3.19/specfuse/loop/data/VERSION +1 -0
  5. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/verification.yml.example +12 -0
  6. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/lint_plan.py +112 -0
  7. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/loop.py +153 -13
  8. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19/specfuse_loop.egg-info}/PKG-INFO +1 -1
  9. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  10. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_attempt_outcome_emission.py +43 -0
  11. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_plan_errors.py +40 -0
  12. specfuse_loop-0.3.19/tests/test_lint_verification_reference.py +177 -0
  13. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_files_changed_guard.py +16 -0
  14. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_orchestration.py +67 -5
  15. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_miniyaml_negative.py +10 -7
  16. specfuse_loop-0.3.17/specfuse/loop/data/VERSION +0 -1
  17. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/LICENSE +0 -0
  18. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/NOTICE +0 -0
  19. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/README.md +0 -0
  20. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/setup.cfg +0 -0
  21. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/__init__.py +0 -0
  22. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/_filelock.py +0 -0
  23. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/adopt_feature.py +0 -0
  24. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  25. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  26. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  27. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/getting-started.md +0 -0
  28. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/methodology.md +0 -0
  29. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/skills.md +0 -0
  30. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/gitignore.snippet +0 -0
  31. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/roadmap.template.md +0 -0
  32. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  33. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/never-touch.md +0 -0
  34. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/result-contract.md +0 -0
  35. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  36. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  37. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/event.schema.json +0 -0
  38. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  39. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  40. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  41. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  42. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/GATE.template.md +0 -0
  43. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  44. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/WU.template.md +0 -0
  45. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/gate_eval.py +0 -0
  46. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/gh_backend.py +0 -0
  47. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/gh_features.py +0 -0
  48. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/scaffold.py +0 -0
  49. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/validate_event.py +0 -0
  50. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  51. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/entry_points.txt +0 -0
  52. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/requires.txt +0 -0
  53. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/top_level.txt +0 -0
  54. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_adopt_feature.py +0 -0
  55. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  56. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autoclose_deferral_visibility.py +0 -0
  57. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync.py +0 -0
  58. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync_consent.py +0 -0
  59. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync_firstrun.py +0 -0
  60. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync_plugin.py +0 -0
  61. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_backend.py +0 -0
  62. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bash_routing.py +0 -0
  63. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  64. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  65. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bookkeeping_no_verify.py +0 -0
  66. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_claude_resolution.py +0 -0
  67. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_close_wu_self_reference.py +0 -0
  68. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_closing_deliverable_guard.py +0 -0
  69. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_console_encoding.py +0 -0
  70. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_cost_tracking.py +0 -0
  71. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_degraded_oracle.py +0 -0
  72. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_deliverable_presence_gate.py +0 -0
  73. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_doctor.py +0 -0
  74. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_driver_integration.py +0 -0
  75. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_driver_lock.py +0 -0
  76. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_duration_tracking.py +0 -0
  77. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_empty_files_escalation.py +0 -0
  78. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_ensure_feature_branch.py +0 -0
  79. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_ensure_feature_branch_base.py +0 -0
  80. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_events_redaction.py +0 -0
  81. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_extra_gates.py +0 -0
  82. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_filelock_portable.py +0 -0
  83. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_force_full_close.py +0 -0
  84. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval.py +0 -0
  85. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval_calibration.py +0 -0
  86. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  87. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval_terminal_wiring.py +0 -0
  88. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gh_backend.py +0 -0
  89. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gh_features.py +0 -0
  90. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_git_env_isolation.py +0 -0
  91. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_hashed_denylist.py +0 -0
  92. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_hashed_denylist_ci.py +0 -0
  93. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_init_integration.py +0 -0
  94. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_interpreter_normalization.py +0 -0
  95. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_leak_findings_redaction.py +0 -0
  96. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_leak_scan.py +0 -0
  97. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_leak_scan_content.py +0 -0
  98. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_learnings_query.py +0 -0
  99. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  100. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lifecycle_integration.py +0 -0
  101. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_bare_produces_path.py +0 -0
  102. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_close_intermediate.py +0 -0
  103. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_close_wu.py +0 -0
  104. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_correlation_id.py +0 -0
  105. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  106. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_oracle_env.py +0 -0
  107. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_plan_next_draft.py +0 -0
  108. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_plan_relative_import.py +0 -0
  109. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_produces_driver_helper.py +0 -0
  110. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_sections.py +0 -0
  111. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  112. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_attempt_evidence.py +0 -0
  113. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_auto_archive.py +0 -0
  114. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_caveman_preamble.py +0 -0
  115. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_close_intermediate.py +0 -0
  116. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_defaults_by_type.py +0 -0
  117. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_effort.py +0 -0
  118. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_failure_note_cap.py +0 -0
  119. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_gate_budget.py +0 -0
  120. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_model_alias.py +0 -0
  121. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_post_pass_invariant.py +0 -0
  122. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_reset_preserving_events.py +0 -0
  123. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_smoke_runner.py +0 -0
  124. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_unsandboxed.py +0 -0
  125. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_zero_token_guard.py +0 -0
  126. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_migrate_legacy.py +0 -0
  127. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_miniyaml_equivalence.py +0 -0
  128. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_planned_cost_lint.py +0 -0
  129. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_prepare_feature.py +0 -0
  130. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_prepare_scaffold_sync.py +0 -0
  131. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_produces_field.py +0 -0
  132. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_redaction_windows_home.py +0 -0
  133. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_resolve_base.py +0 -0
  134. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_result_block.py +0 -0
  135. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_roadmap_add_skill.py +0 -0
  136. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_roadmap_archive_skill.py +0 -0
  137. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_roadmap_row_parser.py +0 -0
  138. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_data_in_sync.py +0 -0
  139. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_doc_hygiene.py +0 -0
  140. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_docs.py +0 -0
  141. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_init.py +0 -0
  142. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_manifest.py +0 -0
  143. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_resources.py +0 -0
  144. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_seed_sanity.py +0 -0
  145. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_upgrade.py +0 -0
  146. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_wiring.py +0 -0
  147. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_skills_vendored_in_sync.py +0 -0
  148. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_squash_commit_hook_crash.py +0 -0
  149. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_template_closing_shapes.py +0 -0
  150. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_terminal_flip_ownership.py +0 -0
  151. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_terminal_flips.py +0 -0
  152. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_timeout_kill_portable.py +0 -0
  153. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_untracked_feature_folder.py +0 -0
  154. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_untracked_file_absorption.py +0 -0
  155. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_windows_gate_exec.py +0 -0
  163. {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/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.17
3
+ Version: 0.3.19
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.17"
7
+ version = "0.3.19"
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.19
@@ -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
 
@@ -40,6 +40,14 @@ VALID_TYPES = {"implementation", "retrospective", "lessons", "docs", "plan-next"
40
40
  "close-intermediate"}
41
41
  VALID_STATUS = {"draft", "pending", "ready", "in_progress", "in_review", "done",
42
42
  "blocked_human", "abandoned"}
43
+ # Feature (PLAN.md) and gate status vocabularies the DRIVER branches on. Kept
44
+ # here (duplicated from loop.py's status literals, like VALID_TYPES/VALID_STATUS
45
+ # above) so the linter validates the same set the driver acts on — an
46
+ # unrecognized status that passes lint and then behaves differently at dispatch
47
+ # is the class #183 (deferred) and #185 exist to close. Keep in sync with
48
+ # find_feature / the gate-status transitions in loop.py.
49
+ VALID_FEATURE_STATUS = {"planned", "active", "deferred", "done", "abandoned"}
50
+ VALID_GATE_STATUS = {"open", "awaiting_review", "passed"}
43
51
  CLOSING_SEQUENCE = ["retrospective", "lessons", "docs", "plan-next"]
44
52
  # New compact closing shapes (FEAT-2026-0015):
45
53
  # non-terminal gate: close-intermediate → plan-next
@@ -138,6 +146,69 @@ def _slice_section(body: str, section_name: str) -> str:
138
146
  return after[:em.start()] if em else after
139
147
 
140
148
 
149
+ # Interpreters / super-generic words that don't distinguish one gate command
150
+ # from another — excluded from a command's signature tokens (#176).
151
+ _GATE_TOKEN_STOP = {
152
+ "python", "python3", "bash", "sh", "run", "exec", "discover",
153
+ "report", "source", "true", "false",
154
+ }
155
+ _GATE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.:+-]{2,}")
156
+ # Verification prose that delegates to the whole gate set — treat as
157
+ # referencing every gate so a legitimate "all gates pass" phrasing is not
158
+ # flagged.
159
+ _UMBRELLA_GATES_RE = re.compile(r"(?i)\b(all|every|entire|full|the)\b[^.\n]{0,24}\bgate")
160
+
161
+
162
+ def _must_reference_gate_signatures(feature_dir: Path) -> list[tuple[str, set[str]]]:
163
+ """Return [(gate_name, signature_tokens)] for OPT-IN must-reference gates (#176).
164
+
165
+ Reads ``<project>/.specfuse/verification.yml`` (``feature_dir`` is
166
+ ``.../.specfuse/features/<name>``) and includes only `code` gates that set
167
+ ``wu_must_reference: true``. This is deliberately opt-in: warning on every
168
+ declared gate is noise on real multi-gate projects (leak-scan, hygiene
169
+ bats, etc. that an individual WU has no business naming). A project flags
170
+ the gates whose fix is mechanical over a WU's own output — formatters,
171
+ auto-fixable linters — the class #175 shows can burn the whole attempt
172
+ budget when a WU forgets them. Empty list (the check no-ops) when the file
173
+ is absent, unparseable, or no gate opts in. Each gate's token set is the
174
+ distinctive words of its command plus the gate name, lowercased.
175
+ """
176
+ vpath = feature_dir.parent.parent / "verification.yml"
177
+ if not vpath.is_file():
178
+ return []
179
+ try:
180
+ cfg = _miniyaml.parse(vpath.read_text()) or {}
181
+ except _miniyaml.MiniYAMLError:
182
+ return []
183
+ out: list[tuple[str, set[str]]] = []
184
+ for gate in cfg.get("code") or []:
185
+ if not isinstance(gate, dict) or gate.get("wu_must_reference") is not True:
186
+ continue
187
+ name = str(gate.get("name") or "").strip()
188
+ command = str(gate.get("command") or "")
189
+ tokens = {t.lower() for t in _GATE_TOKEN_RE.findall(command)
190
+ if t.lower() not in _GATE_TOKEN_STOP}
191
+ if name:
192
+ tokens.add(name.lower())
193
+ if tokens:
194
+ out.append((name or command[:40], tokens))
195
+ return out
196
+
197
+
198
+ def _unreferenced_code_gates(
199
+ verification_text: str, signatures: list[tuple[str, set[str]]],
200
+ ) -> list[str]:
201
+ """Names of code gates whose tokens *verification_text* references none of.
202
+
203
+ An umbrella "all gates" phrasing short-circuits to [] (author delegated).
204
+ """
205
+ if _UMBRELLA_GATES_RE.search(verification_text):
206
+ return []
207
+ low = verification_text.lower()
208
+ return [name for name, tokens in signatures
209
+ if not any(tok in low for tok in tokens)]
210
+
211
+
141
212
  def detect_oracle_verbs(ac_section_text: str) -> list[str]:
142
213
  """Return matched oracle-verb strings found in the AC section text."""
143
214
  found = []
@@ -249,6 +320,22 @@ def lint(feature_dir: Path) -> list[str]:
249
320
  if missing:
250
321
  errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
251
322
 
323
+ # Feature status must be a value the driver recognizes (#185). An
324
+ # unrecognized status passes every other check and then behaves
325
+ # differently at dispatch — the shape #183 (deferred passed lint,
326
+ # dispatched anyway) exposed. Only validate when present; the missing-key
327
+ # check above already covers absence.
328
+ if "status" in fm and fm["status"] not in VALID_FEATURE_STATUS:
329
+ errs.append(
330
+ f"PLAN.md frontmatter status {fm['status']!r} is not a recognized "
331
+ f"feature status {sorted(VALID_FEATURE_STATUS)}"
332
+ )
333
+
334
+ # Opt-in must-reference gate signatures, loaded once for the
335
+ # Verification-reference WARN below (#176). Empty unless the project flags
336
+ # gates with `wu_must_reference: true`.
337
+ must_ref_gate_sigs = _must_reference_gate_signatures(feature_dir)
338
+
252
339
  if "base" in fm:
253
340
  base_val = fm["base"]
254
341
  feature_id_val = fm.get("feature_id", feature_dir.name)
@@ -283,6 +370,12 @@ def lint(feature_dir: Path) -> list[str]:
283
370
  gate_path = feature_dir / gate_file_rel
284
371
  if gate_path.exists():
285
372
  gfm, _ = read_frontmatter(gate_path)
373
+ # Gate status must be a value the driver recognizes (#185).
374
+ if "status" in gfm and gfm["status"] not in VALID_GATE_STATUS:
375
+ errs.append(
376
+ f"{gate_file_rel}: gate status {gfm['status']!r} is not "
377
+ f"a recognized gate status {sorted(VALID_GATE_STATUS)}"
378
+ )
286
379
  if "cost_budget_usd" in gfm:
287
380
  val = gfm["cost_budget_usd"]
288
381
  if isinstance(val, bool) or not isinstance(val, (int, float)):
@@ -400,6 +493,25 @@ def lint(feature_dir: Path) -> list[str]:
400
493
  f"driver. See authoring-work-units §9 + FEAT-2026-0017."
401
494
  )
402
495
 
496
+ # Verification ↔ must-reference gate WARN (#176). For gates a
497
+ # project has flagged `wu_must_reference: true` — those whose fix is
498
+ # mechanical over a WU's own output (formatters, auto-fixable
499
+ # linters) — the driver enforces them regardless of what a WU's
500
+ # Verification names. A session not told to run one writes code that
501
+ # fails it and, per #175, can burn the whole attempt budget on the
502
+ # mechanical failure. Flag at lint time, at zero model cost. Opt-in,
503
+ # so no noise on projects that don't flag any gate.
504
+ if wu_type_val == "implementation" and must_ref_gate_sigs:
505
+ verif_text = _slice_section(wbody, "Verification")
506
+ unref = _unreferenced_code_gates(verif_text, must_ref_gate_sigs)
507
+ if unref:
508
+ print(
509
+ f"WARN: {wfile}: Verification references none of the "
510
+ f"must-reference gate(s): {', '.join(unref)}. The driver "
511
+ f"runs them regardless; name each so the session knows to "
512
+ f"satisfy it before finishing (#176)."
513
+ )
514
+
403
515
  # Deliverable-presence declaration WARN (FEAT-2026-0022/T01).
404
516
  # Advisory: an implementation WU should declare the file path(s) it
405
517
  # 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.17"
66
+ DRIVER_VERSION = "0.3.19"
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
 
@@ -253,15 +266,41 @@ def write_frontmatter_field(path: Path, key: str, value) -> None:
253
266
  def find_feature(arg: str | None) -> Path:
254
267
  if arg:
255
268
  d = FEATURES_DIR / arg if not arg.startswith(".") else Path(arg)
256
- if not (d / "PLAN.md").exists():
269
+ plan = d / "PLAN.md"
270
+ if not plan.exists():
257
271
  sys.exit(f"No PLAN.md under {d}")
272
+ # A `deferred` feature is parked — non-dispatchable by contract (the
273
+ # gate-status skill documents it as "non-dispatchable but resumable;
274
+ # the loop does not auto-resume it"). Refuse an explicit --feature
275
+ # targeting one instead of dispatching it anyway (#183). Malformed
276
+ # frontmatter is left to fail later where the graph is parsed.
277
+ try:
278
+ _fm, _ = read_frontmatter(plan)
279
+ except _miniyaml.MiniYAMLError:
280
+ _fm = {}
281
+ if _fm.get("status") == "deferred":
282
+ sys.exit(
283
+ f"{d.name} is deferred (parked) — the loop does not dispatch "
284
+ f"deferred features. Flip its PLAN.md status back to `active` "
285
+ f"when the blocker clears, then re-run."
286
+ )
258
287
  return d
259
288
  actives = []
260
289
  done_pending_wrap = []
290
+ deferred = []
261
291
  for d in sorted(FEATURES_DIR.glob("*/")):
262
292
  plan = d / "PLAN.md"
263
293
  if plan.exists():
264
- fm, _ = read_frontmatter(plan)
294
+ try:
295
+ fm, _ = read_frontmatter(plan)
296
+ except _miniyaml.MiniYAMLError as exc:
297
+ # One feature's malformed frontmatter must not block dispatch of
298
+ # every OTHER feature. Feature lookup is the earliest and least
299
+ # relevant failure point; skip the bad candidate with a warning
300
+ # rather than aborting the whole scan (#177).
301
+ print(f"warning: skipping {d.name} — unparseable PLAN.md "
302
+ f"frontmatter: {exc}", file=sys.stderr)
303
+ continue
265
304
  if fm.get("status") == "active":
266
305
  actives.append(d)
267
306
  elif fm.get("status") == "done":
@@ -271,10 +310,23 @@ def find_feature(arg: str | None) -> Path:
271
310
  # push + PR are pending.
272
311
  if (d / "RETROSPECTIVE.md").is_file():
273
312
  done_pending_wrap.append(d)
313
+ elif fm.get("status") == "deferred":
314
+ # Parked — non-dispatchable (#183). Never enters `actives`, so
315
+ # a bare invocation won't pick it up; surfaced below so a lone
316
+ # deferred feature reads as parked, not silently absent.
317
+ deferred.append(d)
274
318
  if len(actives) == 1:
275
319
  return actives[0]
276
320
  if not actives:
277
321
  msg = "No active feature. Set a feature's PLAN.md status to 'active'.\n"
322
+ if deferred:
323
+ names = ", ".join(d.name for d in deferred[-3:])
324
+ msg += (
325
+ f" - {len(deferred)} deferred (parked) feature(s), skipped: "
326
+ f"{names}\n"
327
+ f" Flip one's PLAN.md status to `active` to "
328
+ f"resume it.\n"
329
+ )
278
330
  if done_pending_wrap:
279
331
  names = ", ".join(d.name for d in done_pending_wrap[-3:])
280
332
  msg += (
@@ -655,7 +707,56 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
655
707
  encoded = text.encode("utf-8")
656
708
  if len(encoded) <= max_chars:
657
709
  return text
658
- return encoded[-max_chars:].decode("utf-8", errors="ignore")
710
+ # Head+tail, not tail-only (#175): build-tool output puts the actionable
711
+ # part (the list of offending files) up top and boilerplate (help URLs,
712
+ # stack-trace hints) at the bottom. A tail-only slice reliably keeps the
713
+ # noise; splitting the budget preserves both ends.
714
+ marker = "\n...\n"
715
+ budget = max_chars - len(marker.encode("utf-8"))
716
+ if budget <= 0:
717
+ return encoded[-max_chars:].decode("utf-8", errors="ignore")
718
+ head_bytes = budget // 2
719
+ tail_bytes = budget - head_bytes
720
+ head = encoded[:head_bytes].decode("utf-8", errors="ignore")
721
+ tail = encoded[-tail_bytes:].decode("utf-8", errors="ignore")
722
+ return head + marker + tail
723
+
724
+
725
+ # Forward-looking retry directives keyed by failure_class (#175 fix 1). The
726
+ # gate output replayed into a retry describes files the per-attempt reset
727
+ # already discarded, and reads as stale advice about a prior attempt. These
728
+ # reframe it as a standing requirement for the fresh attempt and name the
729
+ # mechanical remedy per class — no per-tool knowledge.
730
+ _RETRY_CLASS_HINT: dict[str, str] = {
731
+ "lint": "This was a lint/format gate — its fix is mechanical: run the "
732
+ "formatter / linter's fix command over your changes and re-run the "
733
+ "gate before declaring done, rather than only rewriting the code.",
734
+ "tests": "Run the failing tests and make them pass before declaring done.",
735
+ "coverage": "Add tests covering the new or changed code to clear the "
736
+ "coverage gate before declaring done.",
737
+ "security": "Resolve the flagged security findings (or add a narrowly "
738
+ "scoped, justified suppression) before declaring done.",
739
+ }
740
+
741
+
742
+ def synthesize_retry_directive(failure_class: "str | None") -> str:
743
+ """Return a forward-looking retry instruction for *failure_class* (#175).
744
+
745
+ Prepended to the failure note fed into the next attempt. The note itself
746
+ is raw gate output about files the reset discarded; without this lead a
747
+ fresh session reads it as stale advice and reproduces the same omission
748
+ (e.g. never running the formatter), failing identically until attempts
749
+ exhaust. The lead reframes the output as a standing requirement and the
750
+ per-class hint names the remedy generically.
751
+ """
752
+ lead = ("The previous attempt's work was DISCARDED before this retry, so "
753
+ "the gate output below refers to files that no longer exist. Do "
754
+ "not read it as stale advice about a past attempt — treat it as a "
755
+ "standing requirement for THIS fresh attempt. ")
756
+ hint = _RETRY_CLASS_HINT.get(
757
+ failure_class or "",
758
+ "Satisfy every declared gate before declaring done.")
759
+ return lead + hint
659
760
 
660
761
 
661
762
  def emit_attempt_outcome(
@@ -1846,6 +1947,22 @@ def verify_files_changed(result: dict, head_before: str) -> list[str]:
1846
1947
  return unchanged
1847
1948
 
1848
1949
 
1950
+ def _annotate_unchanged_paths(paths: "list[str]") -> str:
1951
+ """Format files_changed-mismatch paths, distinguishing missing from unchanged.
1952
+
1953
+ Each declared-but-non-diffing path is labeled ``(missing)`` when it does
1954
+ not exist on disk or ``(no diff)`` when it exists but is byte-identical to
1955
+ head_before. Prior wording listed paths bare, which conflated the two —
1956
+ an existing, unchanged file could read as a deletion claim and seed a
1957
+ wrong root-cause diagnosis (#182). The labels say exactly what was found.
1958
+ """
1959
+ lines = []
1960
+ for p in paths:
1961
+ label = "no diff" if Path(str(p)).exists() else "missing"
1962
+ lines.append(f" - ({label}) {p}")
1963
+ return "\n".join(lines)
1964
+
1965
+
1849
1966
  # Smoke-import runner (FEAT-2026-0008/T03). The conservative pattern matches
1850
1967
  # ONLY a `python3 -c "from X import Y"` line. The agent-authored WU body may
1851
1968
  # declare an existence check naming new symbols this WU just minted; the
@@ -4203,17 +4320,33 @@ def run(
4203
4320
  # against head_before. Treat as a failed attempt: skip
4204
4321
  # squash, reset the tree, record evidence, retry within
4205
4322
  # budget. payload is the list of unchanged paths.
4323
+ _mm_paths = list(payload)
4206
4324
  note = (
4207
- "RESULT block declared `files_changed` paths that "
4208
- "show NO diff against HEAD before this attempt:\n"
4209
- + "\n".join(f" - {p}" for p in payload)
4210
- + "\nEither actually modify them, or correct the "
4211
- "files_changed list to match what you really edited."
4325
+ "The RESULT block declared these `files_changed` "
4326
+ "paths, but each shows NO diff against HEAD before "
4327
+ "this attempt:\n"
4328
+ + _annotate_unchanged_paths(_mm_paths)
4329
+ + "\n\nDeclaring an unchanged path is what fails this "
4330
+ "guard. For each path: if it did NOT need changing, "
4331
+ "OMIT it from `files_changed` (declare only files you "
4332
+ "actually edit); if it SHOULD have changed, make the "
4333
+ "edit this attempt. A `(missing)` path was never "
4334
+ "created; a `(no diff)` path exists but is unchanged."
4212
4335
  )
4336
+ # Populate failure_class/signature/excerpt (#182): a
4337
+ # driver-side rejection previously emitted all-null, so
4338
+ # events.jsonl — and gate-status, which reads only it —
4339
+ # showed empty columns exactly where the diagnosis lives.
4340
+ _mm_sig = ", ".join(
4341
+ sorted({Path(str(p)).name for p in _mm_paths})
4342
+ )[:100] or "unchanged"
4213
4343
  wu_events.append(emit_attempt_outcome(
4214
4344
  wu, attempt, "files_changed_mismatch",
4215
4345
  attempts_usage[-1],
4216
- extras={"unchanged_paths": list(payload)},
4346
+ failure_class="files_changed_mismatch",
4347
+ failure_signature=_mm_sig,
4348
+ failure_excerpt=extract_failure_excerpt(note),
4349
+ extras={"unchanged_paths": _mm_paths},
4217
4350
  ))
4218
4351
  attempt_notes.append((attempt, note))
4219
4352
  failure_note = note
@@ -4241,9 +4374,16 @@ def run(
4241
4374
  + "```diff\n" + _diff + "\n```\n"
4242
4375
  )
4243
4376
  attempt_notes.append((attempt, _evidence))
4244
- failure_note = payload
4245
4377
  _fc, _fs = parse_gate_failure_signature(payload)
4246
4378
  _ex = extract_failure_excerpt(payload)
4379
+ # Prepend a forward-looking directive so the retry reads the
4380
+ # gate output as a standing requirement, not stale advice
4381
+ # about the discarded attempt (#175 fix 1).
4382
+ failure_note = (
4383
+ synthesize_retry_directive(_fc)
4384
+ + "\n\n## Gate output from the discarded attempt\n\n"
4385
+ + payload
4386
+ )
4247
4387
  wu_events.append(emit_attempt_outcome(
4248
4388
  wu, attempt, "failed",
4249
4389
  attempts_usage[-1],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.17
3
+ Version: 0.3.19
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_lint_verification_reference.py
105
106
  tests/test_loop_attempt_evidence.py
106
107
  tests/test_loop_auto_archive.py
107
108
  tests/test_loop_caveman_preamble.py
@@ -304,6 +304,49 @@ class TestExtractFailureExcerpt(unittest.TestCase):
304
304
  self.fail(f"UnicodeDecodeError raised: {e}")
305
305
  self.assertIsInstance(excerpt, str)
306
306
 
307
+ def test_keeps_head_and_tail_on_truncation(self):
308
+ # #175: build-tool output puts the offending-file list up top and
309
+ # boilerplate (help URLs, stack hints) at the bottom. A tail-only
310
+ # slice keeps the noise; head+tail must preserve both ends.
311
+ lines = ["Error: HEAD_MARKER offending file list here"]
312
+ lines += [f"Error: filler line {i} " + "x" * 40 for i in range(400)]
313
+ lines += ["Error: TAIL_MARKER re-run with -X for debug"]
314
+ excerpt = loop.extract_failure_excerpt("\n".join(lines), max_chars=500)
315
+ self.assertLessEqual(len(excerpt.encode("utf-8")), 500)
316
+ self.assertIn("HEAD_MARKER", excerpt)
317
+ self.assertIn("TAIL_MARKER", excerpt)
318
+
319
+
320
+ class TestSynthesizeRetryDirective(unittest.TestCase):
321
+ """#175 fix 1: the retry note must carry a forward-looking lesson, not
322
+ just replay output about files the reset already discarded."""
323
+
324
+ def test_lint_directive_is_forward_looking(self):
325
+ d = loop.synthesize_retry_directive("lint")
326
+ self.assertIn("discard", d.lower())
327
+ # Names the mechanical remedy without per-tool knowledge.
328
+ self.assertIn("format", d.lower())
329
+ self.assertIn("before", d.lower())
330
+
331
+ def test_tests_directive_mentions_tests(self):
332
+ d = loop.synthesize_retry_directive("tests")
333
+ self.assertIn("test", d.lower())
334
+ self.assertIn("before", d.lower())
335
+
336
+ def test_coverage_and_security_have_specific_hints(self):
337
+ self.assertIn("coverage", loop.synthesize_retry_directive("coverage").lower())
338
+ self.assertIn(
339
+ "finding", loop.synthesize_retry_directive("security").lower())
340
+
341
+ def test_unknown_class_falls_back_to_generic_non_empty(self):
342
+ d = loop.synthesize_retry_directive("other")
343
+ self.assertTrue(d.strip())
344
+ self.assertIn("discard", d.lower())
345
+
346
+ def test_none_class_does_not_crash(self):
347
+ d = loop.synthesize_retry_directive(None)
348
+ self.assertTrue(d.strip())
349
+
307
350
 
308
351
  # --------------------------------------------------------------------------- #
309
352
  # TestCumulativeFoldOnRearm #
@@ -104,6 +104,46 @@ class TestLintErrorArms(unittest.TestCase):
104
104
  self.assertTrue(any("frontmatter missing keys" in e for e in errs),
105
105
  f"expected missing-keys error; errs={errs}")
106
106
 
107
+ def test_unknown_feature_status_errors(self):
108
+ """#185: a feature status outside the driver's enum is a lint error."""
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ feat = _build_minimal_feature(Path(tmp))
111
+ plan = feat / "PLAN.md"
112
+ plan.write_text(plan.read_text().replace(
113
+ "status: active", "status: paused", 1))
114
+ errs = lint_plan.lint(feat)
115
+ self.assertTrue(
116
+ any("'paused'" in e and "feature status" in e for e in errs),
117
+ f"expected feature-status error; errs={errs}")
118
+
119
+ def test_known_feature_statuses_do_not_error(self):
120
+ """Every documented feature status passes the vocabulary check."""
121
+ for status in ("planned", "active", "deferred", "done", "abandoned"):
122
+ with tempfile.TemporaryDirectory() as tmp:
123
+ feat = _build_minimal_feature(Path(tmp))
124
+ plan = feat / "PLAN.md"
125
+ plan.write_text(plan.read_text().replace(
126
+ "status: active", f"status: {status}", 1))
127
+ errs = lint_plan.lint(feat)
128
+ self.assertFalse(
129
+ any("feature status" in e for e in errs),
130
+ f"{status!r} should be a valid feature status; errs={errs}")
131
+
132
+ def test_unknown_gate_status_errors(self):
133
+ """#185: a gate status outside the driver's enum is a lint error."""
134
+ with tempfile.TemporaryDirectory() as tmp:
135
+ feat = _build_minimal_feature(Path(tmp))
136
+ (feat / "GATE-01.md").write_text(
137
+ "---\nstatus: reviewing\n---\n\n# Gate 1\n")
138
+ # Point the graph's gate at the file so lint reads it.
139
+ plan = feat / "PLAN.md"
140
+ plan.write_text(plan.read_text().replace(
141
+ " - gate: 1\n", " - gate: 1\n file: GATE-01.md\n", 1))
142
+ errs = lint_plan.lint(feat)
143
+ self.assertTrue(
144
+ any("'reviewing'" in e and "gate status" in e for e in errs),
145
+ f"expected gate-status error; errs={errs}")
146
+
107
147
  def test_plan_no_yaml_block(self):
108
148
  """Line 91: PLAN.md has valid frontmatter but no fenced yaml graph block."""
109
149
  with tempfile.TemporaryDirectory() as tmp: