specfuse-loop 0.3.17__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.17/specfuse_loop.egg-info → specfuse_loop-0.3.18}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/pyproject.toml +1 -1
  3. {specfuse_loop-0.3.17 → 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.17 → specfuse_loop-0.3.18}/specfuse/loop/data/verification.yml.example +12 -0
  6. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/lint_plan.py +87 -0
  7. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/loop.py +84 -6
  8. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18/specfuse_loop.egg-info}/PKG-INFO +1 -1
  9. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  10. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_attempt_outcome_emission.py +43 -0
  11. specfuse_loop-0.3.18/tests/test_lint_verification_reference.py +177 -0
  12. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_orchestration.py +35 -0
  13. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_miniyaml_negative.py +10 -7
  14. specfuse_loop-0.3.17/specfuse/loop/data/VERSION +0 -1
  15. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/LICENSE +0 -0
  16. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/NOTICE +0 -0
  17. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/README.md +0 -0
  18. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/setup.cfg +0 -0
  19. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/__init__.py +0 -0
  20. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/_filelock.py +0 -0
  21. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/adopt_feature.py +0 -0
  22. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  23. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  24. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  25. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/getting-started.md +0 -0
  26. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/methodology.md +0 -0
  27. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/skills.md +0 -0
  28. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/gitignore.snippet +0 -0
  29. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/roadmap.template.md +0 -0
  30. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  31. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/never-touch.md +0 -0
  32. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/result-contract.md +0 -0
  33. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  34. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  35. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/event.schema.json +0 -0
  36. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  37. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  38. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  39. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  40. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/GATE.template.md +0 -0
  41. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  42. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/WU.template.md +0 -0
  43. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/gate_eval.py +0 -0
  44. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/gh_backend.py +0 -0
  45. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/gh_features.py +0 -0
  46. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/scaffold.py +0 -0
  47. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse/loop/validate_event.py +0 -0
  48. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  49. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/entry_points.txt +0 -0
  50. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/requires.txt +0 -0
  51. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/top_level.txt +0 -0
  52. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_adopt_feature.py +0 -0
  53. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  54. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autoclose_deferral_visibility.py +0 -0
  55. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync.py +0 -0
  56. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync_consent.py +0 -0
  57. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync_firstrun.py +0 -0
  58. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_autosync_plugin.py +0 -0
  59. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_backend.py +0 -0
  60. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bash_routing.py +0 -0
  61. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  62. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  63. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_bookkeeping_no_verify.py +0 -0
  64. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_claude_resolution.py +0 -0
  65. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_close_wu_self_reference.py +0 -0
  66. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_closing_deliverable_guard.py +0 -0
  67. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_console_encoding.py +0 -0
  68. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_cost_tracking.py +0 -0
  69. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_degraded_oracle.py +0 -0
  70. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_deliverable_presence_gate.py +0 -0
  71. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_doctor.py +0 -0
  72. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_driver_integration.py +0 -0
  73. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_driver_lock.py +0 -0
  74. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_duration_tracking.py +0 -0
  75. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_empty_files_escalation.py +0 -0
  76. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch.py +0 -0
  77. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch_base.py +0 -0
  78. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_events_redaction.py +0 -0
  79. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_extra_gates.py +0 -0
  80. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_filelock_portable.py +0 -0
  81. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_force_full_close.py +0 -0
  82. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval.py +0 -0
  83. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval_calibration.py +0 -0
  84. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  85. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gate_eval_terminal_wiring.py +0 -0
  86. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gh_backend.py +0 -0
  87. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_gh_features.py +0 -0
  88. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_git_env_isolation.py +0 -0
  89. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_hashed_denylist.py +0 -0
  90. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_hashed_denylist_ci.py +0 -0
  91. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_init_integration.py +0 -0
  92. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_interpreter_normalization.py +0 -0
  93. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_leak_findings_redaction.py +0 -0
  94. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_leak_scan.py +0 -0
  95. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_leak_scan_content.py +0 -0
  96. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_learnings_query.py +0 -0
  97. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  98. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lifecycle_integration.py +0 -0
  99. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_bare_produces_path.py +0 -0
  100. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_close_intermediate.py +0 -0
  101. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_close_wu.py +0 -0
  102. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id.py +0 -0
  103. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  104. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_oracle_env.py +0 -0
  105. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_plan_errors.py +0 -0
  106. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_plan_next_draft.py +0 -0
  107. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_plan_relative_import.py +0 -0
  108. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_produces_driver_helper.py +0 -0
  109. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_sections.py +0 -0
  110. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  111. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_attempt_evidence.py +0 -0
  112. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_auto_archive.py +0 -0
  113. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_caveman_preamble.py +0 -0
  114. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_close_intermediate.py +0 -0
  115. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_defaults_by_type.py +0 -0
  116. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_effort.py +0 -0
  117. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_failure_note_cap.py +0 -0
  118. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_files_changed_guard.py +0 -0
  119. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_gate_budget.py +0 -0
  120. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_model_alias.py +0 -0
  121. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_post_pass_invariant.py +0 -0
  122. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_reset_preserving_events.py +0 -0
  123. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_smoke_runner.py +0 -0
  124. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_unsandboxed.py +0 -0
  125. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_loop_zero_token_guard.py +0 -0
  126. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_migrate_legacy.py +0 -0
  127. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_miniyaml_equivalence.py +0 -0
  128. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_planned_cost_lint.py +0 -0
  129. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_prepare_feature.py +0 -0
  130. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_prepare_scaffold_sync.py +0 -0
  131. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_produces_field.py +0 -0
  132. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_redaction_windows_home.py +0 -0
  133. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_resolve_base.py +0 -0
  134. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_result_block.py +0 -0
  135. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_roadmap_add_skill.py +0 -0
  136. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_roadmap_archive_skill.py +0 -0
  137. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_roadmap_row_parser.py +0 -0
  138. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_data_in_sync.py +0 -0
  139. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_doc_hygiene.py +0 -0
  140. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_docs.py +0 -0
  141. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_init.py +0 -0
  142. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_manifest.py +0 -0
  143. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_resources.py +0 -0
  144. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_seed_sanity.py +0 -0
  145. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_upgrade.py +0 -0
  146. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_scaffold_wiring.py +0 -0
  147. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_skills_vendored_in_sync.py +0 -0
  148. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_squash_commit_hook_crash.py +0 -0
  149. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_template_closing_shapes.py +0 -0
  150. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_terminal_flip_ownership.py +0 -0
  151. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_terminal_flips.py +0 -0
  152. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_timeout_kill_portable.py +0 -0
  153. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_untracked_feature_folder.py +0 -0
  154. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_untracked_file_absorption.py +0 -0
  155. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.17 → specfuse_loop-0.3.18}/tests/test_windows_gate_exec.py +0 -0
  163. {specfuse_loop-0.3.17 → 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.17
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.17"
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.17"
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":
@@ -655,7 +677,56 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
655
677
  encoded = text.encode("utf-8")
656
678
  if len(encoded) <= max_chars:
657
679
  return text
658
- 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
659
730
 
660
731
 
661
732
  def emit_attempt_outcome(
@@ -4241,9 +4312,16 @@ def run(
4241
4312
  + "```diff\n" + _diff + "\n```\n"
4242
4313
  )
4243
4314
  attempt_notes.append((attempt, _evidence))
4244
- failure_note = payload
4245
4315
  _fc, _fs = parse_gate_failure_signature(payload)
4246
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
+ )
4247
4325
  wu_events.append(emit_attempt_outcome(
4248
4326
  wu, attempt, "failed",
4249
4327
  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.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,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 #
@@ -0,0 +1,177 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """#176: warn when an implementation WU's Verification omits a declared code gate.
6
+
7
+ The driver enforces the full `code` gate set regardless of what a WU's
8
+ Verification names, so the two can silently disagree — the authoring-side
9
+ trigger of #175. This lint catches it before dispatch at zero model cost.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import tempfile
16
+ import unittest
17
+ from contextlib import redirect_stdout
18
+ from pathlib import Path
19
+
20
+ from tests._loop_loader import load_lint
21
+
22
+ lint_plan = load_lint()
23
+
24
+ # Only `format` opts in via wu_must_reference; tests/lint are NOT checked.
25
+ _VERIFICATION = """\
26
+ code:
27
+ - name: tests
28
+ command: "python3 -m unittest discover -s tests -v"
29
+ - name: lint
30
+ command: "ruff check specfuse tests"
31
+ - name: format
32
+ command: "./mvnw spotless:check"
33
+ wu_must_reference: true
34
+ """
35
+
36
+
37
+ class TestMustReferenceGateSignatureHelpers(unittest.TestCase):
38
+
39
+ def _feature_with_verification(self, tmp: str, yml: str | None) -> Path:
40
+ root = Path(tmp)
41
+ fdir = root / ".specfuse" / "features" / "FEAT-2026-9401-x"
42
+ fdir.mkdir(parents=True)
43
+ if yml is not None:
44
+ (root / ".specfuse" / "verification.yml").write_text(yml)
45
+ return fdir
46
+
47
+ def test_only_flagged_gate_is_included(self):
48
+ with tempfile.TemporaryDirectory() as tmp:
49
+ fdir = self._feature_with_verification(tmp, _VERIFICATION)
50
+ sigs = dict(lint_plan._must_reference_gate_signatures(fdir))
51
+ # tests/lint are NOT flagged → excluded; only format opts in.
52
+ self.assertEqual(set(sigs), {"format"})
53
+ self.assertIn("spotless:check", sigs["format"])
54
+ self.assertIn("format", sigs["format"]) # gate name is a token
55
+
56
+ def test_no_flag_anywhere_returns_empty(self):
57
+ with tempfile.TemporaryDirectory() as tmp:
58
+ plain = ("code:\n - name: tests\n"
59
+ " command: \"pytest -q\"\n")
60
+ fdir = self._feature_with_verification(tmp, plain)
61
+ self.assertEqual(lint_plan._must_reference_gate_signatures(fdir), [])
62
+
63
+ def test_no_verification_file_returns_empty(self):
64
+ with tempfile.TemporaryDirectory() as tmp:
65
+ fdir = self._feature_with_verification(tmp, None)
66
+ self.assertEqual(lint_plan._must_reference_gate_signatures(fdir), [])
67
+
68
+ def test_unparseable_verification_returns_empty(self):
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ fdir = self._feature_with_verification(tmp, "code: {bad: flow}\n")
71
+ self.assertEqual(lint_plan._must_reference_gate_signatures(fdir), [])
72
+
73
+ def test_unreferenced_detects_omitted_gate(self):
74
+ sigs = [("tests", {"tests", "unittest"}),
75
+ ("format", {"format", "spotless:check"})]
76
+ # Names tests but not the formatter → only 'format' is unreferenced.
77
+ text = "Run the unittest suite; all tests pass."
78
+ self.assertEqual(lint_plan._unreferenced_code_gates(text, sigs),
79
+ ["format"])
80
+
81
+ def test_umbrella_phrase_short_circuits(self):
82
+ sigs = [("format", {"format", "spotless:check"})]
83
+ for phrase in ("all declared gates pass", "every gate is green",
84
+ "the full gate set runs clean"):
85
+ self.assertEqual(
86
+ lint_plan._unreferenced_code_gates(phrase, sigs), [],
87
+ f"{phrase!r} should short-circuit")
88
+
89
+ def test_all_referenced_is_empty(self):
90
+ sigs = [("tests", {"tests"}), ("format", {"spotless:check"})]
91
+ text = "tests pass; ran spotless:check clean."
92
+ self.assertEqual(lint_plan._unreferenced_code_gates(text, sigs), [])
93
+
94
+
95
+ class TestVerificationReferenceLintWarn(unittest.TestCase):
96
+
97
+ def _make_feature(self, tmp: str, verification_body: str,
98
+ yml: str | None = _VERIFICATION) -> Path:
99
+ root = Path(tmp)
100
+ fdir = root / ".specfuse" / "features" / "FEAT-2026-9401-x"
101
+ fdir.mkdir(parents=True)
102
+ if yml is not None:
103
+ (root / ".specfuse" / "verification.yml").write_text(yml)
104
+ (fdir / "PLAN.md").write_text(
105
+ "---\n"
106
+ "feature_id: FEAT-2026-9401\n"
107
+ "title: verification-reference lint\n"
108
+ "branch: feat/vref\n"
109
+ "roadmap_goal: Check the Verification-reference WARN.\n"
110
+ "status: active\n"
111
+ "---\n\n# Plan\n\n```yaml\n"
112
+ "gates:\n"
113
+ " - gate: 1\n"
114
+ " file: GATE-01.md\n"
115
+ " work_units:\n"
116
+ " - id: FEAT-2026-9401/T01\n"
117
+ " file: WU-01-impl.md\n"
118
+ " depends_on: []\n"
119
+ " - id: FEAT-2026-9401/G1-CLOSE\n"
120
+ " file: WU-90-close.md\n"
121
+ " depends_on: [FEAT-2026-9401/T01]\n"
122
+ "```\n"
123
+ )
124
+ (fdir / "WU-01-impl.md").write_text(
125
+ "---\nid: FEAT-2026-9401/T01\ntype: implementation\n"
126
+ "status: done\nattempts: 1\nproduces: [src/x.py]\n---\n\n"
127
+ "# Title\n\n**Context.**\nAdd a file.\n\n"
128
+ "**Acceptance criteria.**\nThe code works.\n\n"
129
+ "**Do not touch.**\nNothing.\n\n"
130
+ f"**Verification.**\n{verification_body}\n\n"
131
+ "**Escalation triggers.**\nEmit blocked if wrong.\n"
132
+ )
133
+ (fdir / "WU-90-close.md").write_text(
134
+ "---\nid: FEAT-2026-9401/G1-CLOSE\ntype: close\n"
135
+ "status: done\nattempts: 1\n---\n\n# Close\n"
136
+ )
137
+ (fdir / "GATE-01.md").write_text("---\nstatus: open\n---\n\n# Gate 1\n")
138
+ return fdir
139
+
140
+ def _lint(self, fdir: Path) -> str:
141
+ buf = io.StringIO()
142
+ with redirect_stdout(buf):
143
+ lint_plan.lint(fdir)
144
+ return buf.getvalue()
145
+
146
+ def test_warns_when_formatter_gate_omitted(self):
147
+ with tempfile.TemporaryDirectory() as tmp:
148
+ fdir = self._make_feature(
149
+ tmp, "Run the unittest suite and ruff; all pass.")
150
+ out = self._lint(fdir)
151
+ self.assertIn("WARN", out)
152
+ # Only the omitted formatter gate is named; the referenced
153
+ # tests/lint gates are not listed as unreferenced.
154
+ listed = out.split("gate(s):", 1)[1].split("\n", 1)[0]
155
+ self.assertIn("format", listed)
156
+ self.assertNotIn("tests", listed)
157
+ self.assertNotIn("lint", listed)
158
+
159
+ def test_silent_when_all_gates_named(self):
160
+ with tempfile.TemporaryDirectory() as tmp:
161
+ fdir = self._make_feature(
162
+ tmp, "unittest suite passes; ruff clean; spotless:check clean.")
163
+ self.assertNotIn("references none", self._lint(fdir))
164
+
165
+ def test_silent_on_umbrella_phrase(self):
166
+ with tempfile.TemporaryDirectory() as tmp:
167
+ fdir = self._make_feature(tmp, "All declared code gates pass.")
168
+ self.assertNotIn("references none", self._lint(fdir))
169
+
170
+ def test_silent_when_no_verification_yml(self):
171
+ with tempfile.TemporaryDirectory() as tmp:
172
+ fdir = self._make_feature(tmp, "anything at all", yml=None)
173
+ self.assertNotIn("references none", self._lint(fdir))
174
+
175
+
176
+ if __name__ == "__main__":
177
+ unittest.main()
@@ -320,6 +320,41 @@ class TestFindFeatureSelection(unittest.TestCase):
320
320
  result = loop.find_feature("FEAT-2026-9502-explicit")
321
321
  self.assertEqual(result, fdir)
322
322
 
323
+ def test_null_in_frontmatter_does_not_crash_lookup(self):
324
+ """#177 fix 3: an explicit null in frontmatter parses fine now."""
325
+ fdir = self._features_dir / "FEAT-2026-9502-nullkey"
326
+ fdir.mkdir(parents=True)
327
+ (fdir / "PLAN.md").write_text(
328
+ "---\nfeature_id: FEAT-2026-9502\nstatus: active\n"
329
+ "supersedes_gate_of: null\n---\n\n# Plan\n"
330
+ )
331
+ self.assertEqual(loop.find_feature(None), fdir)
332
+
333
+ def test_unparseable_candidate_is_skipped_not_fatal(self):
334
+ """#177 fix 2: a malformed PLAN.md in one feature must not block
335
+ dispatch of a different, valid active feature."""
336
+ bad = self._features_dir / "FEAT-2026-9502-bad"
337
+ bad.mkdir(parents=True)
338
+ # Single-quoted scalar is unsupported → MiniYAMLError on this file only.
339
+ (bad / "PLAN.md").write_text(
340
+ "---\nfeature_id: FEAT-2026-9502\nstatus: 'active'\n---\n\n# Plan\n"
341
+ )
342
+ good = self._write_plan("FEAT-2026-9502-good", "active")
343
+ # The bad one is skipped; the good active feature still resolves.
344
+ self.assertEqual(loop.find_feature(None), good)
345
+
346
+ def test_read_frontmatter_error_names_the_file(self):
347
+ """#177 fix 1: a parse error carries the file path, not just a
348
+ block-relative line number."""
349
+ bad = self._features_dir / "FEAT-2026-9502-badfm"
350
+ bad.mkdir(parents=True)
351
+ plan = bad / "PLAN.md"
352
+ plan.write_text("---\nstatus: 'active'\n---\n\n# Plan\n")
353
+ with self.assertRaises(loop._miniyaml.MiniYAMLError) as ctx:
354
+ loop.read_frontmatter(plan)
355
+ self.assertIn("PLAN.md", str(ctx.exception))
356
+ self.assertIn(str(plan), str(ctx.exception))
357
+
323
358
  def test_deferred_feature_is_not_auto_selected(self):
324
359
  """Issue #106: a `deferred` foldered feature is non-dispatchable — the
325
360
  driver skips it exactly like a non-active feature (parked, resumable)."""
@@ -98,13 +98,16 @@ class TestUnsupportedConstructsFailLoudly(unittest.TestCase):
98
98
  def test_top_level_indent_nonzero_rejected(self):
99
99
  self._expect_error(" foo: 1\n", "indent 0")
100
100
 
101
- # --- explicit nulls / forbidden boolean spellings ---
102
-
103
- def test_explicit_null_marker_rejected(self):
104
- self._expect_error("foo: null\n", "explicit null marker")
105
-
106
- def test_tilde_null_marker_rejected(self):
107
- self._expect_error("foo: ~\n", "explicit null marker")
101
+ # --- explicit nulls now ACCEPTED (#177); forbidden boolean spellings ---
102
+
103
+ def test_explicit_null_spellings_accepted_as_none(self):
104
+ # #177: null/~/Null/NULL map to None, matching yaml.safe_load — the
105
+ # explicit-null rejection used to crash feature lookup with a
106
+ # traceback. Boolean spellings below stay rejected (Norway problem).
107
+ for spelling in ("null", "Null", "NULL", "~"):
108
+ self.assertEqual(
109
+ miniyaml.parse(f"foo: {spelling}\n"), {"foo": None},
110
+ f"{spelling!r} should parse to None")
108
111
 
109
112
  def test_titlecase_boolean_rejected(self):
110
113
  self._expect_error("foo: True\n", "lowercase", "true")
@@ -1 +0,0 @@
1
- 0.3.17
File without changes
File without changes
File without changes