specfuse-loop 0.3.18__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.18/specfuse_loop.egg-info → specfuse_loop-0.3.19}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.19/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/lint_plan.py +25 -0
  5. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/loop.py +70 -8
  6. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_plan_errors.py +40 -0
  8. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_files_changed_guard.py +16 -0
  9. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_orchestration.py +32 -5
  10. specfuse_loop-0.3.18/specfuse/loop/data/VERSION +0 -1
  11. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/LICENSE +0 -0
  12. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/NOTICE +0 -0
  13. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/README.md +0 -0
  14. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/setup.cfg +0 -0
  15. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/__init__.py +0 -0
  16. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/_filelock.py +0 -0
  17. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/_miniyaml.py +0 -0
  18. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/adopt_feature.py +0 -0
  19. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  20. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  21. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  22. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/getting-started.md +0 -0
  23. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/methodology.md +0 -0
  24. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/skills.md +0 -0
  25. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/gitignore.snippet +0 -0
  26. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/roadmap.template.md +0 -0
  27. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  28. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/never-touch.md +0 -0
  29. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/result-contract.md +0 -0
  30. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  31. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  32. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/event.schema.json +0 -0
  33. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  34. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  35. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  36. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  37. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/GATE.template.md +0 -0
  38. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  39. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/WU.template.md +0 -0
  40. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/data/verification.yml.example +0 -0
  41. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/gate_eval.py +0 -0
  42. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/gh_backend.py +0 -0
  43. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/gh_features.py +0 -0
  44. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/scaffold.py +0 -0
  45. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse/loop/validate_event.py +0 -0
  46. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/SOURCES.txt +0 -0
  47. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  48. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/entry_points.txt +0 -0
  49. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/requires.txt +0 -0
  50. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/top_level.txt +0 -0
  51. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_adopt_feature.py +0 -0
  52. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  53. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_attempt_outcome_emission.py +0 -0
  54. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_autoclose_deferral_visibility.py +0 -0
  55. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_autosync.py +0 -0
  56. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_autosync_consent.py +0 -0
  57. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_autosync_firstrun.py +0 -0
  58. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_autosync_plugin.py +0 -0
  59. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_backend.py +0 -0
  60. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_bash_routing.py +0 -0
  61. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  62. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  63. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_bookkeeping_no_verify.py +0 -0
  64. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_claude_resolution.py +0 -0
  65. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_close_wu_self_reference.py +0 -0
  66. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_closing_deliverable_guard.py +0 -0
  67. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_console_encoding.py +0 -0
  68. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_cost_tracking.py +0 -0
  69. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_degraded_oracle.py +0 -0
  70. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_deliverable_presence_gate.py +0 -0
  71. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_doctor.py +0 -0
  72. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_driver_integration.py +0 -0
  73. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_driver_lock.py +0 -0
  74. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_duration_tracking.py +0 -0
  75. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_empty_files_escalation.py +0 -0
  76. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_ensure_feature_branch.py +0 -0
  77. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_ensure_feature_branch_base.py +0 -0
  78. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_events_redaction.py +0 -0
  79. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_extra_gates.py +0 -0
  80. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_filelock_portable.py +0 -0
  81. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_force_full_close.py +0 -0
  82. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_gate_eval.py +0 -0
  83. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_gate_eval_calibration.py +0 -0
  84. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  85. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_gate_eval_terminal_wiring.py +0 -0
  86. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_gh_backend.py +0 -0
  87. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_gh_features.py +0 -0
  88. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_git_env_isolation.py +0 -0
  89. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_hashed_denylist.py +0 -0
  90. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_hashed_denylist_ci.py +0 -0
  91. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_init_integration.py +0 -0
  92. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_interpreter_normalization.py +0 -0
  93. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_leak_findings_redaction.py +0 -0
  94. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_leak_scan.py +0 -0
  95. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_leak_scan_content.py +0 -0
  96. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_learnings_query.py +0 -0
  97. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  98. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lifecycle_integration.py +0 -0
  99. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_bare_produces_path.py +0 -0
  100. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_close_intermediate.py +0 -0
  101. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_close_wu.py +0 -0
  102. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_correlation_id.py +0 -0
  103. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  104. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_oracle_env.py +0 -0
  105. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_plan_next_draft.py +0 -0
  106. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_plan_relative_import.py +0 -0
  107. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_produces_driver_helper.py +0 -0
  108. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_sections.py +0 -0
  109. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  110. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_lint_verification_reference.py +0 -0
  111. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_attempt_evidence.py +0 -0
  112. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_auto_archive.py +0 -0
  113. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_caveman_preamble.py +0 -0
  114. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_close_intermediate.py +0 -0
  115. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_defaults_by_type.py +0 -0
  116. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_effort.py +0 -0
  117. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_failure_note_cap.py +0 -0
  118. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_gate_budget.py +0 -0
  119. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_model_alias.py +0 -0
  120. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_post_pass_invariant.py +0 -0
  121. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_reset_preserving_events.py +0 -0
  122. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_smoke_runner.py +0 -0
  123. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_unsandboxed.py +0 -0
  124. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_loop_zero_token_guard.py +0 -0
  125. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_migrate_legacy.py +0 -0
  126. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_miniyaml_equivalence.py +0 -0
  127. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_miniyaml_negative.py +0 -0
  128. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_planned_cost_lint.py +0 -0
  129. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_prepare_feature.py +0 -0
  130. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_prepare_scaffold_sync.py +0 -0
  131. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_produces_field.py +0 -0
  132. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_redaction_windows_home.py +0 -0
  133. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_resolve_base.py +0 -0
  134. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_result_block.py +0 -0
  135. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_roadmap_add_skill.py +0 -0
  136. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_roadmap_archive_skill.py +0 -0
  137. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_roadmap_row_parser.py +0 -0
  138. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_data_in_sync.py +0 -0
  139. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_doc_hygiene.py +0 -0
  140. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_docs.py +0 -0
  141. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_init.py +0 -0
  142. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_manifest.py +0 -0
  143. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_resources.py +0 -0
  144. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_seed_sanity.py +0 -0
  145. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_upgrade.py +0 -0
  146. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_scaffold_wiring.py +0 -0
  147. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_skills_vendored_in_sync.py +0 -0
  148. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_squash_commit_hook_crash.py +0 -0
  149. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_template_closing_shapes.py +0 -0
  150. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_terminal_flip_ownership.py +0 -0
  151. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_terminal_flips.py +0 -0
  152. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_timeout_kill_portable.py +0 -0
  153. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_untracked_feature_folder.py +0 -0
  154. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_untracked_file_absorption.py +0 -0
  155. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.18 → specfuse_loop-0.3.19}/tests/test_windows_gate_exec.py +0 -0
  163. {specfuse_loop-0.3.18 → 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.18
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.18"
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"
@@ -0,0 +1 @@
1
+ 0.3.19
@@ -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
@@ -312,6 +320,17 @@ def lint(feature_dir: Path) -> list[str]:
312
320
  if missing:
313
321
  errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
314
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
+
315
334
  # Opt-in must-reference gate signatures, loaded once for the
316
335
  # Verification-reference WARN below (#176). Empty unless the project flags
317
336
  # gates with `wu_must_reference: true`.
@@ -351,6 +370,12 @@ def lint(feature_dir: Path) -> list[str]:
351
370
  gate_path = feature_dir / gate_file_rel
352
371
  if gate_path.exists():
353
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
+ )
354
379
  if "cost_budget_usd" in gfm:
355
380
  val = gfm["cost_budget_usd"]
356
381
  if isinstance(val, bool) or not isinstance(val, (int, float)):
@@ -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.18"
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
@@ -266,11 +266,28 @@ def write_frontmatter_field(path: Path, key: str, value) -> None:
266
266
  def find_feature(arg: str | None) -> Path:
267
267
  if arg:
268
268
  d = FEATURES_DIR / arg if not arg.startswith(".") else Path(arg)
269
- if not (d / "PLAN.md").exists():
269
+ plan = d / "PLAN.md"
270
+ if not plan.exists():
270
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
+ )
271
287
  return d
272
288
  actives = []
273
289
  done_pending_wrap = []
290
+ deferred = []
274
291
  for d in sorted(FEATURES_DIR.glob("*/")):
275
292
  plan = d / "PLAN.md"
276
293
  if plan.exists():
@@ -293,10 +310,23 @@ def find_feature(arg: str | None) -> Path:
293
310
  # push + PR are pending.
294
311
  if (d / "RETROSPECTIVE.md").is_file():
295
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)
296
318
  if len(actives) == 1:
297
319
  return actives[0]
298
320
  if not actives:
299
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
+ )
300
330
  if done_pending_wrap:
301
331
  names = ", ".join(d.name for d in done_pending_wrap[-3:])
302
332
  msg += (
@@ -1917,6 +1947,22 @@ def verify_files_changed(result: dict, head_before: str) -> list[str]:
1917
1947
  return unchanged
1918
1948
 
1919
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
+
1920
1966
  # Smoke-import runner (FEAT-2026-0008/T03). The conservative pattern matches
1921
1967
  # ONLY a `python3 -c "from X import Y"` line. The agent-authored WU body may
1922
1968
  # declare an existence check naming new symbols this WU just minted; the
@@ -4274,17 +4320,33 @@ def run(
4274
4320
  # against head_before. Treat as a failed attempt: skip
4275
4321
  # squash, reset the tree, record evidence, retry within
4276
4322
  # budget. payload is the list of unchanged paths.
4323
+ _mm_paths = list(payload)
4277
4324
  note = (
4278
- "RESULT block declared `files_changed` paths that "
4279
- "show NO diff against HEAD before this attempt:\n"
4280
- + "\n".join(f" - {p}" for p in payload)
4281
- + "\nEither actually modify them, or correct the "
4282
- "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."
4283
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"
4284
4343
  wu_events.append(emit_attempt_outcome(
4285
4344
  wu, attempt, "files_changed_mismatch",
4286
4345
  attempts_usage[-1],
4287
- 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},
4288
4350
  ))
4289
4351
  attempt_notes.append((attempt, note))
4290
4352
  failure_note = note
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.18
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
@@ -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:
@@ -133,6 +133,15 @@ class TestVerifyFilesChanged(unittest.TestCase):
133
133
  self.assertEqual(
134
134
  loop.verify_files_changed(result, self.head), ["ignored.log"])
135
135
 
136
+ def test_annotate_distinguishes_missing_from_no_diff(self):
137
+ # #182: never label an existing-but-unchanged file as deleted. An
138
+ # on-disk path → "(no diff)"; an absent path → "(missing)".
139
+ (self.root / "present.py").write_text("x\n")
140
+ out = loop._annotate_unchanged_paths(["present.py", "gone.py"])
141
+ self.assertIn("(no diff) present.py", out)
142
+ self.assertIn("(missing) gone.py", out)
143
+ self.assertNotIn("deleted", out)
144
+
136
145
 
137
146
  def write_minimal_feature(root: Path, feature_id: str, slug: str,
138
147
  branch: str, wus: list) -> Path:
@@ -302,6 +311,13 @@ class TestFilesChangedMismatchSpinsIntegration(unittest.TestCase):
302
311
  self.assertEqual(ev["payload"]["unchanged_paths"],
303
312
  ["sentinel.py"],
304
313
  "payload must name the sentinel path")
314
+ # #182: driver-side rejections must populate the diagnosis
315
+ # fields, not emit all-null — gate-status reads only events.jsonl.
316
+ self.assertEqual(ev["payload"]["failure_class"],
317
+ "files_changed_mismatch")
318
+ self.assertIn("sentinel.py", ev["payload"]["failure_signature"])
319
+ self.assertTrue(ev["payload"]["failure_excerpt"],
320
+ "failure_excerpt must not be null/empty")
305
321
 
306
322
  types = [e["event_type"] for e in events]
307
323
  self.assertNotIn("task_completed", types,
@@ -343,6 +343,31 @@ class TestFindFeatureSelection(unittest.TestCase):
343
343
  # The bad one is skipped; the good active feature still resolves.
344
344
  self.assertEqual(loop.find_feature(None), good)
345
345
 
346
+ def test_explicit_deferred_feature_is_refused(self):
347
+ """#183: `--feature` on a deferred feature must refuse, not dispatch."""
348
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
349
+ with self.assertRaises(SystemExit) as ctx:
350
+ loop.find_feature("FEAT-2026-9502-parked")
351
+ msg = str(ctx.exception.code).lower()
352
+ self.assertIn("deferred", msg)
353
+ self.assertIn("active", msg) # points at the fix
354
+
355
+ def test_deferred_not_treated_as_active_in_scan(self):
356
+ """#183: a lone deferred feature yields 'no active', named as parked."""
357
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
358
+ with self.assertRaises(SystemExit) as ctx:
359
+ loop.find_feature(None)
360
+ msg = str(ctx.exception.code)
361
+ self.assertIn("No active feature", msg)
362
+ self.assertIn("parked", msg.lower())
363
+ self.assertIn("FEAT-2026-9502-parked", msg)
364
+
365
+ def test_deferred_does_not_block_a_real_active(self):
366
+ """A deferred feature alongside one active resolves to the active."""
367
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
368
+ good = self._write_plan("FEAT-2026-9502-live", "active")
369
+ self.assertEqual(loop.find_feature(None), good)
370
+
346
371
  def test_read_frontmatter_error_names_the_file(self):
347
372
  """#177 fix 1: a parse error carries the file path, not just a
348
373
  block-relative line number."""
@@ -370,11 +395,13 @@ class TestFindFeatureSelection(unittest.TestCase):
370
395
  self._write_plan("FEAT-2026-9502-parked", "deferred")
371
396
  self.assertEqual(loop.find_feature(None), active_dir)
372
397
 
373
- def test_deferred_still_selectable_by_explicit_arg(self):
374
- """A human can still target a `deferred` feature explicitly (e.g. to
375
- inspect it); only auto-selection skips it."""
376
- fdir = self._write_plan("FEAT-2026-9502-parked", "deferred")
377
- self.assertEqual(loop.find_feature("FEAT-2026-9502-parked"), fdir)
398
+ # NOTE (#183): the prior test asserted a `deferred` feature was still
399
+ # dispatchable via an explicit --feature (only auto-selection skipped it,
400
+ # from #106). That contradicted the shipped contract — the PLAN template,
401
+ # methodology.md, and the gate-status skill all document `deferred` as
402
+ # "non-dispatchable". #183 resolves the contradiction in favor of the docs:
403
+ # explicit dispatch is now refused (see test_explicit_deferred_feature_is_
404
+ # refused above), so that assertion is intentionally removed.
378
405
 
379
406
  def test_explicit_arg_without_plan_exits(self):
380
407
  """Lines 188-190: explicit dir name with no PLAN.md → sys.exit."""
@@ -1 +0,0 @@
1
- 0.3.18
File without changes
File without changes
File without changes
File without changes