specfuse-loop 0.3.14__tar.gz → 0.3.16__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 (162) hide show
  1. {specfuse_loop-0.3.14/specfuse_loop.egg-info → specfuse_loop-0.3.16}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.16/specfuse/loop/_filelock.py +44 -0
  4. specfuse_loop-0.3.16/specfuse/loop/data/VERSION +1 -0
  5. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/getting-started.md +10 -0
  6. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/loop.py +290 -45
  7. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16/specfuse_loop.egg-info}/PKG-INFO +1 -1
  8. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/SOURCES.txt +11 -0
  9. specfuse_loop-0.3.16/tests/test_autoclose_deferral_visibility.py +67 -0
  10. specfuse_loop-0.3.16/tests/test_bash_routing.py +110 -0
  11. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_bookkeeping_commit_hook_crash.py +29 -14
  12. specfuse_loop-0.3.16/tests/test_bookkeeping_no_verify.py +67 -0
  13. specfuse_loop-0.3.16/tests/test_claude_resolution.py +104 -0
  14. specfuse_loop-0.3.16/tests/test_console_encoding.py +70 -0
  15. specfuse_loop-0.3.16/tests/test_filelock_portable.py +98 -0
  16. specfuse_loop-0.3.16/tests/test_interpreter_normalization.py +122 -0
  17. specfuse_loop-0.3.16/tests/test_loop_reset_preserving_events.py +294 -0
  18. specfuse_loop-0.3.16/tests/test_redaction_windows_home.py +54 -0
  19. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_squash_commit_hook_crash.py +31 -14
  20. specfuse_loop-0.3.16/tests/test_timeout_kill_portable.py +113 -0
  21. specfuse_loop-0.3.16/tests/test_windows_gate_exec.py +68 -0
  22. specfuse_loop-0.3.14/specfuse/loop/data/VERSION +0 -1
  23. specfuse_loop-0.3.14/tests/test_loop_reset_preserving_events.py +0 -142
  24. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/LICENSE +0 -0
  25. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/NOTICE +0 -0
  26. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/README.md +0 -0
  27. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/setup.cfg +0 -0
  28. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/__init__.py +0 -0
  29. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/_miniyaml.py +0 -0
  30. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/adopt_feature.py +0 -0
  31. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  32. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  33. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  34. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/methodology.md +0 -0
  35. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/docs/skills.md +0 -0
  36. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/gitignore.snippet +0 -0
  37. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/roadmap.template.md +0 -0
  38. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  39. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/never-touch.md +0 -0
  40. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/result-contract.md +0 -0
  41. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  42. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  43. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/event.schema.json +0 -0
  44. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  45. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  46. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  47. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  48. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/GATE.template.md +0 -0
  49. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  50. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/templates/WU.template.md +0 -0
  51. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/data/verification.yml.example +0 -0
  52. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/gate_eval.py +0 -0
  53. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/gh_backend.py +0 -0
  54. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/gh_features.py +0 -0
  55. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/lint_plan.py +0 -0
  56. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/scaffold.py +0 -0
  57. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse/loop/validate_event.py +0 -0
  58. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  59. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/entry_points.txt +0 -0
  60. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/requires.txt +0 -0
  61. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/specfuse_loop.egg-info/top_level.txt +0 -0
  62. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_adopt_feature.py +0 -0
  63. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  64. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_attempt_outcome_emission.py +0 -0
  65. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_autosync.py +0 -0
  66. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_autosync_consent.py +0 -0
  67. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_autosync_firstrun.py +0 -0
  68. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_autosync_plugin.py +0 -0
  69. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_backend.py +0 -0
  70. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  71. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_close_wu_self_reference.py +0 -0
  72. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_closing_deliverable_guard.py +0 -0
  73. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_cost_tracking.py +0 -0
  74. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_degraded_oracle.py +0 -0
  75. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_deliverable_presence_gate.py +0 -0
  76. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_doctor.py +0 -0
  77. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_driver_integration.py +0 -0
  78. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_driver_lock.py +0 -0
  79. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_duration_tracking.py +0 -0
  80. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_empty_files_escalation.py +0 -0
  81. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_ensure_feature_branch.py +0 -0
  82. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_ensure_feature_branch_base.py +0 -0
  83. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_events_redaction.py +0 -0
  84. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_extra_gates.py +0 -0
  85. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_force_full_close.py +0 -0
  86. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_gate_eval.py +0 -0
  87. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_gate_eval_calibration.py +0 -0
  88. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  89. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_gate_eval_terminal_wiring.py +0 -0
  90. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_gh_backend.py +0 -0
  91. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_gh_features.py +0 -0
  92. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_git_env_isolation.py +0 -0
  93. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_hashed_denylist.py +0 -0
  94. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_hashed_denylist_ci.py +0 -0
  95. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_init_integration.py +0 -0
  96. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_leak_findings_redaction.py +0 -0
  97. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_leak_scan.py +0 -0
  98. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_leak_scan_content.py +0 -0
  99. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_learnings_query.py +0 -0
  100. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  101. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lifecycle_integration.py +0 -0
  102. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_bare_produces_path.py +0 -0
  103. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_close_intermediate.py +0 -0
  104. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_close_wu.py +0 -0
  105. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_correlation_id.py +0 -0
  106. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  107. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_oracle_env.py +0 -0
  108. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_plan_errors.py +0 -0
  109. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_plan_next_draft.py +0 -0
  110. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_plan_relative_import.py +0 -0
  111. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_produces_driver_helper.py +0 -0
  112. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_sections.py +0 -0
  113. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  114. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_auto_archive.py +0 -0
  115. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_caveman_preamble.py +0 -0
  116. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_close_intermediate.py +0 -0
  117. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_defaults_by_type.py +0 -0
  118. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_effort.py +0 -0
  119. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_failure_note_cap.py +0 -0
  120. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_files_changed_guard.py +0 -0
  121. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_gate_budget.py +0 -0
  122. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_model_alias.py +0 -0
  123. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_orchestration.py +0 -0
  124. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_post_pass_invariant.py +0 -0
  125. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_smoke_runner.py +0 -0
  126. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_unsandboxed.py +0 -0
  127. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_loop_zero_token_guard.py +0 -0
  128. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_migrate_legacy.py +0 -0
  129. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_miniyaml_equivalence.py +0 -0
  130. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_miniyaml_negative.py +0 -0
  131. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_planned_cost_lint.py +0 -0
  132. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_prepare_feature.py +0 -0
  133. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_prepare_scaffold_sync.py +0 -0
  134. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_produces_field.py +0 -0
  135. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_resolve_base.py +0 -0
  136. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_result_block.py +0 -0
  137. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_roadmap_add_skill.py +0 -0
  138. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_roadmap_archive_skill.py +0 -0
  139. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_roadmap_row_parser.py +0 -0
  140. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_data_in_sync.py +0 -0
  141. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_doc_hygiene.py +0 -0
  142. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_docs.py +0 -0
  143. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_init.py +0 -0
  144. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_manifest.py +0 -0
  145. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_resources.py +0 -0
  146. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_seed_sanity.py +0 -0
  147. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_upgrade.py +0 -0
  148. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_scaffold_wiring.py +0 -0
  149. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_skills_vendored_in_sync.py +0 -0
  150. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_template_closing_shapes.py +0 -0
  151. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_terminal_flip_ownership.py +0 -0
  152. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_terminal_flips.py +0 -0
  153. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_untracked_feature_folder.py +0 -0
  154. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_untracked_file_absorption.py +0 -0
  155. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_upgrade_integration.py +0 -0
  156. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_upgrade_merge_gate.py +0 -0
  157. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_validate_event.py +0 -0
  158. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_verdict_coupling.py +0 -0
  159. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_verify_empty_gate_set.py +0 -0
  160. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_version_consistency.py +0 -0
  161. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/tests/test_version_skew.py +0 -0
  162. {specfuse_loop-0.3.14 → specfuse_loop-0.3.16}/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.14
3
+ Version: 0.3.16
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.14"
7
+ version = "0.3.16"
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,44 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Cross-platform advisory lock for the driver's single-instance working-tree lock.
6
+
7
+ The kernel must auto-release the lock on process exit, including SIGKILL, with
8
+ no cleanup step — this is why POSIX `fcntl.flock` and Windows `msvcrt.locking`
9
+ are used here instead of any userspace primitive. Pidfiles are ruled out: the
10
+ pid-holder can die without a chance to remove its file, leaving a stale lock
11
+ that stalls or is silently skipped by the next launch.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ def acquire_tree_lock(specfuse_dir: Path):
21
+ """Open .specfuse/.loop.lock and acquire a non-blocking exclusive lock.
22
+
23
+ Returns the open file object; caller keeps it alive for the process
24
+ lifetime — the OS auto-releases on fd/handle close or process death
25
+ (SIGKILL included), so no stale-lock cleanup is ever needed.
26
+ Raises BlockingIOError if another process already holds the lock.
27
+ """
28
+ lock_path = specfuse_dir / ".loop.lock"
29
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
30
+ fd = lock_path.open("w")
31
+ try:
32
+ if sys.platform == "win32":
33
+ import msvcrt
34
+ try:
35
+ msvcrt.locking(fd.fileno(), msvcrt.LK_NBLCK, 1)
36
+ except OSError as exc:
37
+ raise BlockingIOError from exc
38
+ else:
39
+ import fcntl
40
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
41
+ except BlockingIOError:
42
+ fd.close()
43
+ raise
44
+ return fd
@@ -0,0 +1 @@
1
+ 0.3.16
@@ -31,6 +31,16 @@ python3 -m pip install specfuse
31
31
  > venv) — that's what puts `specfuse-loop` / `specfuse-lint` on PATH for the gate
32
32
  > commands to find.
33
33
 
34
+ > **Windows: Git-Bash is required.** The driver runs natively on Windows (no WSL),
35
+ > but it routes gate commands through **Git-Bash** so a target repo's
36
+ > `verification.yml` (`&&`, `exit 1 || exit 0`, `bats`, globs) runs unchanged.
37
+ > Install [Git for Windows](https://gitforwindows.org/) — it ships `bash.exe`, no
38
+ > admin and no WSL feature needed — before running the loop. The driver prefers
39
+ > the Git-for-Windows `bash.exe` over the `C:\Windows\System32\bash.exe` WSL
40
+ > launcher; it also normalizes `python3` gate tokens to your Windows interpreter
41
+ > and resolves the `claude` CLI via `PATHEXT`, so gate commands authored on
42
+ > POSIX run as-is.
43
+
34
44
  Enable the skills plugin in Claude Code (one-time):
35
45
 
36
46
  ```
@@ -41,12 +41,12 @@ from __future__ import annotations
41
41
 
42
42
  import argparse
43
43
  import datetime as dt
44
- import fcntl
45
44
  import hashlib
46
45
  import json
47
46
  import logging
48
47
  import os
49
48
  import re
49
+ import shutil
50
50
  import signal
51
51
  import subprocess
52
52
  import sys
@@ -54,6 +54,7 @@ import time
54
54
  from dataclasses import dataclass, field
55
55
  from pathlib import Path
56
56
 
57
+ from . import _filelock
57
58
  from . import _miniyaml
58
59
  from . import scaffold as _scaffold
59
60
  from .gate_eval import evaluate_auto_close, AutoCloseDecision
@@ -62,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
62
63
  REPO_ROOT = SPECFUSE_DIR.parent
63
64
  FEATURES_DIR = SPECFUSE_DIR / "features"
64
65
  VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
65
- DRIVER_VERSION = "0.3.14"
66
+ DRIVER_VERSION = "0.3.16"
66
67
  # Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
67
68
  # version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
68
69
  # the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
@@ -471,21 +472,26 @@ def build_event(event_type: str, correlation_id: str, payload: dict) -> dict:
471
472
 
472
473
 
473
474
  # Matches macOS ("/Users/" + "<name>/") and Linux ("/home/" + "<name>/") home
474
- # prefixes at runtime without containing a literal "/Users/" substring in
475
- # source, so this file's own staged diff never re-trips the structural leak-scan.
475
+ # prefixes, plus the Windows shape ("C:" + "\Users\" + "<name>" + "\" or "/",
476
+ # any drive letter, case-insensitive), at runtime without containing a literal
477
+ # "/Users/" substring in source, so this file's own staged diff never re-trips
478
+ # the structural leak-scan.
476
479
  _HOME_PATH_RE = re.compile(r"/(?:Users|home)/[^/\s]+/")
480
+ _WIN_HOME_PATH_RE = re.compile(r"[A-Za-z]:\\Users\\[^\\/\s]+[\\/]", re.IGNORECASE)
477
481
  _HOME_PATH_PLACEHOLDER = "<redacted-home>/"
478
482
 
479
483
 
480
484
  def _redact_home_paths(value):
481
485
  """Recursively redact absolute home-directory prefixes from a JSON-ish value.
482
486
 
483
- Walks dict/list/str/scalar and replaces every "/Users/" + "<name>/" or
484
- "/home/" + "<name>/" match in string leaves with a stable placeholder.
485
- Other text is preserved verbatim; idempotent (a second pass is a no-op).
487
+ Walks dict/list/str/scalar and replaces every "/Users/" + "<name>/",
488
+ "/home/" + "<name>/", or Windows "<drive>:\\Users\\" + "<name>" + "\\"/"/"
489
+ match in string leaves with a stable placeholder. Other text is preserved
490
+ verbatim; idempotent (a second pass is a no-op).
486
491
  """
487
492
  if isinstance(value, str):
488
- return _HOME_PATH_RE.sub(_HOME_PATH_PLACEHOLDER, value)
493
+ value = _HOME_PATH_RE.sub(_HOME_PATH_PLACEHOLDER, value)
494
+ return _WIN_HOME_PATH_RE.sub(_HOME_PATH_PLACEHOLDER, value)
489
495
  if isinstance(value, dict):
490
496
  return {k: _redact_home_paths(v) for k, v in value.items()}
491
497
  if isinstance(value, list):
@@ -1098,7 +1104,9 @@ def prepare_feature(feat_fm: dict, feature_dir: "Path", feature_id: str) -> None
1098
1104
  git("add", "--", *add_paths)
1099
1105
  staged = git("status", "--porcelain", "--", *add_paths)
1100
1106
  if staged:
1101
- git("commit", "-m", f"chore: scaffold feature {feature_id}")
1107
+ # --no-verify: driver-authored scaffold commit, same trust context as
1108
+ # the WU squash (issue #156) — gated by `--all` + CI, not the pre-commit.
1109
+ git("commit", "--no-verify", "-m", f"chore: scaffold feature {feature_id}")
1102
1110
  print(f"Prepared: committed feature folder on branch '{branch}'.")
1103
1111
  else:
1104
1112
  print(f"Prepare: feature folder already committed on branch '{branch}'.")
@@ -1205,22 +1213,14 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
1205
1213
 
1206
1214
 
1207
1215
  def acquire_tree_lock(specfuse_dir: Path):
1208
- """Open .specfuse/.loop.lock and acquire a non-blocking exclusive flock.
1216
+ """Open .specfuse/.loop.lock and acquire a non-blocking exclusive lock.
1209
1217
 
1210
- Returns the open file object; caller keeps it alive for the process
1211
- lifetime the kernel auto-releases on fd close or process exit (SIGKILL
1212
- included), so no stale-lock cleanup is ever needed.
1218
+ Delegates to `_filelock`, which selects fcntl.flock (POSIX) or
1219
+ msvcrt.locking (Windows) by sys.platform. See `_filelock` for why the
1220
+ kernel-auto-release property this preserves rules out pidfiles.
1213
1221
  Raises BlockingIOError if another process already holds the lock.
1214
1222
  """
1215
- lock_path = specfuse_dir / ".loop.lock"
1216
- lock_path.parent.mkdir(parents=True, exist_ok=True)
1217
- fd = lock_path.open("w")
1218
- try:
1219
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
1220
- except BlockingIOError:
1221
- fd.close()
1222
- raise
1223
- return fd
1223
+ return _filelock.acquire_tree_lock(specfuse_dir)
1224
1224
 
1225
1225
 
1226
1226
  def write_cost_to_wu(backend, wu: WorkUnit, cum_usage: dict) -> None:
@@ -1390,8 +1390,15 @@ def commit_bookkeeping(paths: list, message: str) -> str | None:
1390
1390
  git("add", "-f", *existing)
1391
1391
  if not git("status", "--porcelain"):
1392
1392
  return None # all paths were already in their committed state
1393
+ # --no-verify: internal driver commits belong to the `--all` scan trust
1394
+ # context (denylist + gitleaks), NOT the human-oriented structural
1395
+ # pre-commit hook (issue #156). The structural regexes are deliberately
1396
+ # excluded from `--all` as too noisy for agent-authored / fixture-heavy
1397
+ # content; running them on the driver's own commits produces false-positive
1398
+ # blocks. `leak_scan.py --all` + the pre-push/PR surfaces remain the gates.
1393
1399
  res = subprocess.run(
1394
- ["git", "commit", "-m", message], capture_output=True, text=True,
1400
+ ["git", "commit", "--no-verify", "-m", message],
1401
+ capture_output=True, text=True,
1395
1402
  )
1396
1403
  if res.returncode != 0:
1397
1404
  raise BookkeepingCommitError(
@@ -1403,7 +1410,49 @@ def commit_bookkeeping(paths: list, message: str) -> str | None:
1403
1410
  return git("rev-parse", "HEAD")
1404
1411
 
1405
1412
 
1406
- def reset_preserving_events(head_before: str, events_path: Path) -> None:
1413
+ def _clean_attempt_untracked(
1414
+ untracked_before: "set[str] | frozenset[str]", events_path: Path,
1415
+ ) -> None:
1416
+ """Delete untracked files that appeared since the *untracked_before* snapshot.
1417
+
1418
+ Helper for `reset_preserving_events`; see its docstring for why. Deletes
1419
+ FILES only — empty directories are left behind, which is harmless (git
1420
+ does not track directories) and avoids racing the driver's own mid-run
1421
+ scratch dirs.
1422
+
1423
+ Never deletes: paths in *untracked_before* (the operator's, per #150),
1424
+ *events_path* (driver-managed, and untracked on a fresh branch), or
1425
+ gitignored paths (`untracked_paths` honors .gitignore, so a feature's
1426
+ `work/` scratch dir never enters the delete set).
1427
+
1428
+ Best-effort: any git or filesystem error is swallowed. A failure to clean
1429
+ reverts to the old leftover behavior, which must not crash the run.
1430
+ """
1431
+ try:
1432
+ root = Path(git("rev-parse", "--show-toplevel"))
1433
+ candidates = untracked_paths() - set(untracked_before)
1434
+ except (subprocess.CalledProcessError, OSError):
1435
+ return
1436
+ try:
1437
+ keep = events_path.resolve()
1438
+ except OSError:
1439
+ keep = None
1440
+ for rel in sorted(candidates):
1441
+ target = root / rel
1442
+ try:
1443
+ if keep is not None and target.resolve() == keep:
1444
+ continue
1445
+ if target.is_file() or target.is_symlink():
1446
+ target.unlink()
1447
+ except OSError:
1448
+ continue
1449
+
1450
+
1451
+ def reset_preserving_events(
1452
+ head_before: str,
1453
+ events_path: Path,
1454
+ untracked_before: "set[str] | frozenset[str] | None" = None,
1455
+ ) -> None:
1407
1456
  """`git reset --hard <head_before>` without losing events.jsonl content.
1408
1457
 
1409
1458
  The hard-reset is the methodology's "wipe agent's edits before we write our
@@ -1426,11 +1475,27 @@ def reset_preserving_events(head_before: str, events_path: Path) -> None:
1426
1475
 
1427
1476
  Subsequent `flush_events` calls then append to the preserved content;
1428
1477
  `commit_bookkeeping` captures the full history.
1478
+
1479
+ `untracked_before` (#162) is the per-WU untracked snapshot taken at
1480
+ dispatch. When supplied, untracked files that appeared SINCE it are
1481
+ deleted after the reset. A hard reset rolls back tracked content but
1482
+ leaves untracked files on disk, and the snapshot is taken once per WU
1483
+ (outside the attempt loop) — so without this, attempt 1's new files
1484
+ survive into attempt 2, where they are indistinguishable from attempt 2's
1485
+ own work: `verify_files_changed` passes them and `squash_commit` commits
1486
+ them, letting an attempt pass on its predecessor's leftovers.
1487
+
1488
+ Absence is inert BY DESIGN. An empty set would read as "the operator had
1489
+ nothing untracked" and delete the entire untracked working tree; `None`
1490
+ means "no snapshot available, do not clean" and preserves the pre-#162
1491
+ behavior for callers that cannot supply one.
1429
1492
  """
1430
1493
  saved = events_path.read_text() if events_path.is_file() else None
1431
1494
  git("reset", "--hard", head_before)
1432
1495
  if saved is not None:
1433
1496
  events_path.write_text(saved)
1497
+ if untracked_before is not None:
1498
+ _clean_attempt_untracked(untracked_before, events_path)
1434
1499
 
1435
1500
 
1436
1501
  class SquashCommitError(RuntimeError):
@@ -1476,8 +1541,14 @@ def squash_commit(
1476
1541
  if not git("diff", "--cached", "--name-only"):
1477
1542
  return None
1478
1543
  msg = f"feat: {wu.title}\n\nFeature: {wu.wu_id}"
1544
+ # --no-verify: see commit_bookkeeping — the driver's WU squashes are gated
1545
+ # by `leak_scan.py --all` + CI, not the human-oriented structural pre-commit
1546
+ # hook (issue #156). Without this, an agent-authored fixture token the
1547
+ # structural scan flags (but `--all` allowlists) rejects the squash and the
1548
+ # driver blocks `spinning_detected` after 3 retries.
1479
1549
  res = subprocess.run(
1480
- ["git", "commit", "-m", msg], capture_output=True, text=True,
1550
+ ["git", "commit", "--no-verify", "-m", msg],
1551
+ capture_output=True, text=True,
1481
1552
  )
1482
1553
  if res.returncode != 0:
1483
1554
  raise SquashCommitError(
@@ -1599,6 +1670,7 @@ def dispatch(wu: WorkUnit, failure_note: str | None,
1599
1670
  + truncate_failure_note(failure_note))
1600
1671
  cmd = [p.replace("{model}", wu.model).replace("{effort}", wu.effort)
1601
1672
  for p in CLAUDE_CMD]
1673
+ cmd = resolve_claude_cmd(cmd)
1602
1674
  if wu.unsandboxed:
1603
1675
  # Per-WU sandbox-escape. Audited via the unsandboxed_dispatch event
1604
1676
  # emitted in run()'s attempt loop; rationale lives in WU frontmatter.
@@ -1717,6 +1789,39 @@ def extract_smoke_imports(wu_body: str) -> list[str]:
1717
1789
  return out
1718
1790
 
1719
1791
 
1792
+ LEADING_PYTHON3_RE = re.compile(r'^(\s*)python3\b')
1793
+
1794
+
1795
+ def resolve_windows_interpreter() -> str:
1796
+ """Return the interpreter token to substitute for a leading `python3`.
1797
+
1798
+ Windows Python ships `python` and the `py` launcher, never `python3`. A
1799
+ bare `python` token is the safest form to hand to Git-Bash `bash -c` —
1800
+ unlike a raw `sys.executable` path, it has no backslashes for MSYS to
1801
+ mangle (cross-repo contract, see GATE-02-REVIEW.md). No real-Windows-runner
1802
+ round-trip has verified this from the sandbox; T08 owns that proof.
1803
+ """
1804
+ return "python"
1805
+
1806
+
1807
+ def normalize_interpreter(command: str) -> str:
1808
+ """Rewrite a leading `python3` token to the Windows interpreter, else no-op.
1809
+
1810
+ POSIX (`sys.platform != "win32"`): returns `command` unchanged — target
1811
+ repos and the driver's own smoke imports already assume `python3` exists.
1812
+ Windows: rewrites ONLY a `python3` token anchored at the start of the
1813
+ command string (optionally after leading whitespace); a `python3`
1814
+ appearing later, e.g. inside a quoted argument, is left untouched.
1815
+ """
1816
+ if sys.platform != "win32":
1817
+ return command
1818
+ match = LEADING_PYTHON3_RE.match(command)
1819
+ if not match:
1820
+ return command
1821
+ interpreter = resolve_windows_interpreter()
1822
+ return match.group(1) + interpreter + command[match.end():]
1823
+
1824
+
1720
1825
  def run_smoke_imports(commands: list[str], cwd: Path) -> tuple[bool, str]:
1721
1826
  """Run each smoke-import command in `cwd` in declared order.
1722
1827
 
@@ -1731,6 +1836,7 @@ def run_smoke_imports(commands: list[str], cwd: Path) -> tuple[bool, str]:
1731
1836
  active venv per `[loop-driver-operation]`).
1732
1837
  """
1733
1838
  for cmd in commands:
1839
+ cmd = normalize_interpreter(cmd)
1734
1840
  proc = subprocess.run( # nosec B602
1735
1841
  cmd, shell=True, capture_output=True, text=True, cwd=str(cwd),
1736
1842
  )
@@ -1827,6 +1933,65 @@ def load_verification() -> dict:
1827
1933
  return _miniyaml.parse(VERIFICATION_PATH.read_text()) or {}
1828
1934
 
1829
1935
 
1936
+ def resolve_claude_cmd(cmd: list[str]) -> list[str]:
1937
+ """Resolve a bare `claude` argv[0] to its executable path on Windows.
1938
+
1939
+ `subprocess.run(..., shell=False)` calls `CreateProcess` on Windows, which
1940
+ does not consult `PATHEXT` — a bare `"claude"` argv[0] will not resolve to
1941
+ the `claude.cmd` shim the Windows install ships. `shutil.which` does honor
1942
+ `PATHEXT`, so use it to find the real executable and substitute it as
1943
+ argv[0]; the rest of `cmd` (flags, model/effort substitution) is untouched.
1944
+ On POSIX, `shell=False` with a bare `claude` already resolves via PATH, so
1945
+ `cmd` is returned unchanged.
1946
+ """
1947
+ if sys.platform != "win32":
1948
+ return cmd
1949
+ resolved = shutil.which(cmd[0])
1950
+ if not resolved:
1951
+ raise SystemExit(
1952
+ f"claude not found on PATH — install Claude Code or add its "
1953
+ f"install directory to PATH (resolve_claude_cmd: "
1954
+ f"shutil.which('{cmd[0]}') returned None)."
1955
+ )
1956
+ return [resolved] + cmd[1:]
1957
+
1958
+
1959
+ def resolve_bash() -> str | None:
1960
+ """Resolve a Git-for-Windows `bash.exe` for routing gate commands.
1961
+
1962
+ Prefers deriving it from `git --exec-path` (the git-core dir inside a
1963
+ Git-for-Windows install; `bash.exe` lives at the install's `bin/bash.exe`,
1964
+ i.e. two levels up from git-core then into `bin`) over a bare
1965
+ `shutil.which("bash")`, which on a Windows host can resolve to
1966
+ `C:\\Windows\\System32\\bash.exe` — the WSL launcher, which fails with no
1967
+ distro installed. Returns None if no Git-Bash can be found.
1968
+ """
1969
+ try:
1970
+ completed = subprocess.run(
1971
+ ["git", "--exec-path"], capture_output=True, text=True, check=True,
1972
+ )
1973
+ exec_path = str(completed.stdout).strip()
1974
+ if exec_path:
1975
+ candidate = Path(exec_path).parent.parent / "bin" / "bash.exe"
1976
+ if candidate.is_file():
1977
+ return str(candidate)
1978
+ except Exception:
1979
+ pass
1980
+
1981
+ # Manual PATH scan rather than shutil.which(): shutil.which's win32 branch
1982
+ # touches _winapi, which is unavailable when running under a mocked
1983
+ # sys.platform on a non-Windows test host.
1984
+ for dirname in os.environ.get("PATH", "").split(os.pathsep):
1985
+ if not dirname:
1986
+ continue
1987
+ for name in ("bash.exe", "bash"):
1988
+ candidate = Path(dirname) / name
1989
+ if candidate.is_file() and "system32" not in str(candidate).lower():
1990
+ return str(candidate)
1991
+
1992
+ return None
1993
+
1994
+
1830
1995
  def verify(wu: WorkUnit, feature_dir: Path,
1831
1996
  cfg: dict | None = None) -> tuple[bool, str]:
1832
1997
  """Driver runs the gates itself — the exit oracle. Agent self-report is advisory.
@@ -1873,6 +2038,7 @@ def verify(wu: WorkUnit, feature_dir: Path,
1873
2038
  results, ok_all = [], True
1874
2039
  for gate in gate_set:
1875
2040
  command = gate["command"].replace("{feature_dir}", str(feature_dir))
2041
+ command = normalize_interpreter(command)
1876
2042
  # shell=True is intentional: gate commands are authored by the user in
1877
2043
  # verification.yml and routinely use shell features (pipes, &&, glob,
1878
2044
  # redirects — e.g. `dotnet build && dotnet test --no-build`). The input
@@ -1883,13 +2049,41 @@ def verify(wu: WorkUnit, feature_dir: Path,
1883
2049
  # output pipe so communicate() stalls past the timeout):
1884
2050
  # 1. stdin=DEVNULL — a gate that reads stdin (e.g. a test calling input())
1885
2051
  # gets EOF immediately and fails fast instead of blocking forever.
1886
- # 2. start_new_session + killpg on timeout the gate runs in its own
1887
- # process group; on timeout the WHOLE group (shell + grandchildren) is
2052
+ # 2. start_new_session + killpg (POSIX) / CREATE_NEW_PROCESS_GROUP +
2053
+ # taskkill (Windows) on timeout the gate runs in its own process
2054
+ # group; on timeout the WHOLE group (shell + grandchildren) is
1888
2055
  # killed, so the timer actually returns.
2056
+ is_win32 = sys.platform == "win32"
2057
+ spawn_kwargs = (
2058
+ {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)}
2059
+ if is_win32
2060
+ else {"start_new_session": True}
2061
+ )
2062
+ if is_win32:
2063
+ # cmd.exe (the shell=True default on Windows) does not understand
2064
+ # the POSIX shell syntax (&&, ||, globs, pipes) that real
2065
+ # verification.yml gate commands routinely use. Route through
2066
+ # Git-Bash instead so gate commands run unmodified across
2067
+ # platforms.
2068
+ bash = resolve_bash()
2069
+ if not bash:
2070
+ ok_all = False
2071
+ results.append(
2072
+ f"### {gate['name']}: FAIL\n```\n$ {command}\n"
2073
+ f"No Git-Bash found — install Git for Windows "
2074
+ f"(https://git-scm.com/download/win) so gate commands can "
2075
+ f"run through bash.exe.\n```"
2076
+ )
2077
+ continue
2078
+ popen_argv = [bash, "-c", command]
2079
+ popen_kwargs = dict(shell=False)
2080
+ else:
2081
+ popen_argv = command
2082
+ popen_kwargs = dict(shell=True) # nosec B604
1889
2083
  proc = subprocess.Popen( # nosec B602
1890
- command, shell=True, stdin=subprocess.DEVNULL,
2084
+ popen_argv, stdin=subprocess.DEVNULL,
1891
2085
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
1892
- start_new_session=True,
2086
+ **popen_kwargs, **spawn_kwargs,
1893
2087
  )
1894
2088
  try:
1895
2089
  out, _ = proc.communicate(timeout=GATE_TIMEOUT_SECONDS)
@@ -1904,10 +2098,16 @@ def verify(wu: WorkUnit, feature_dir: Path,
1904
2098
  ok = False
1905
2099
  tail = tail + [f"DEGRADED ORACLE: {degraded}"]
1906
2100
  except subprocess.TimeoutExpired:
1907
- try:
1908
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
1909
- except (ProcessLookupError, PermissionError):
1910
- proc.kill()
2101
+ if is_win32:
2102
+ subprocess.run(
2103
+ ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
2104
+ capture_output=True,
2105
+ )
2106
+ else:
2107
+ try:
2108
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
2109
+ except (ProcessLookupError, PermissionError):
2110
+ proc.kill()
1911
2111
  out, _ = proc.communicate()
1912
2112
  ok = False
1913
2113
  tail = (out or "").strip().splitlines()[-10:] + [
@@ -2400,7 +2600,17 @@ def write_stub_retrospective_terminal(
2400
2600
  f"- predicate_version: {decision.predicate_version}\n"
2401
2601
  f"- gate_total_cost: ${total_cost:.2f}\n"
2402
2602
  f"- gate_budget: {budget_str}\n"
2403
- f"- reasons: [] (auto=True)\n"
2603
+ f"- reasons: [] (auto=True)\n\n"
2604
+ # issue #157: terminal auto-close skips the close body AND has no
2605
+ # downstream gate to catch the gap — the operator is the last line.
2606
+ f"## What the loop did NOT verify (gate {gate_number})\n\n"
2607
+ f"This terminal gate auto-closed on-plan; the full close ceremony did not\n"
2608
+ f"run, so the per-criterion deferred-verification list was **not**\n"
2609
+ f"enumerated, and there is no downstream gate to reconcile it. Before\n"
2610
+ f"treating the feature as fully verified, the operator MUST confirm every\n"
2611
+ f"acceptance criterion was actually verified in-loop (not only by artifact\n"
2612
+ f"shape). Any AC deferred to a post-merge or real-system step must be\n"
2613
+ f"recorded and completed now.\n"
2404
2614
  )
2405
2615
  if retro.exists():
2406
2616
  with retro.open("a") as fh:
@@ -2528,7 +2738,18 @@ def append_stub_retrospective_intermediate(
2528
2738
  f"- predicate_version: {decision.predicate_version}\n"
2529
2739
  f"- gate_total_cost: ${total_cost:.2f}\n"
2530
2740
  f"- gate_budget: {budget_str}\n"
2531
- f"- reasons: [] (auto=True)\n"
2741
+ f"- reasons: [] (auto=True)\n\n"
2742
+ # issue #157: the auto-close path skips the close-intermediate body, so
2743
+ # the per-criterion deferred-verification list is never enumerated. Emit
2744
+ # an explicit marker rather than silently omitting it — the predicate
2745
+ # cannot reason over ACs, so it flags the gap for downstream reconciliation.
2746
+ f"## What the loop did NOT verify (gate {gate_number})\n\n"
2747
+ f"This gate auto-closed on-plan; the full close-intermediate ceremony did\n"
2748
+ f"not run, so the per-criterion deferred-verification list was **not**\n"
2749
+ f"enumerated. Any acceptance criterion whose verification is deferred\n"
2750
+ f"(loop-sandbox limit, cross-repo coordination, real-system access) is\n"
2751
+ f"unrecorded here. Gate {gate_number + 1}'s close MUST reconcile these\n"
2752
+ f"before the feature's terminal verdict — auto-close cannot enumerate them.\n"
2532
2753
  )
2533
2754
  if retro.exists():
2534
2755
  with retro.open("a") as fh:
@@ -3638,7 +3859,8 @@ def run(
3638
3859
  wu, attempt, "zero_token_skip",
3639
3860
  attempts_usage[-1],
3640
3861
  ))
3641
- reset_preserving_events(head_before, events_path)
3862
+ reset_preserving_events(head_before, events_path,
3863
+ untracked_before=untracked_before)
3642
3864
  print(f" ZERO-TOKEN attempt {attempt}/{MAX_ATTEMPTS} "
3643
3865
  f"— no agent output, skipping")
3644
3866
  continue
@@ -3649,7 +3871,8 @@ def run(
3649
3871
  # reset wipe the flip — the silent-state-loss bug.
3650
3872
  # Use reset_preserving_events to keep prior WU's
3651
3873
  # flushed-but-uncommitted events.jsonl entries.
3652
- reset_preserving_events(head_before, events_path)
3874
+ reset_preserving_events(head_before, events_path,
3875
+ untracked_before=untracked_before)
3653
3876
  backend.set_wu(wu, "status", "blocked_human")
3654
3877
  write_cost_to_wu(backend, wu, cum_usage)
3655
3878
  wu_events.append(emit_attempt_outcome(
@@ -3693,7 +3916,8 @@ def run(
3693
3916
  # written just above — record the failure with git's
3694
3917
  # stderr, and retry within budget (MAX_ATTEMPTS
3695
3918
  # exhaustion escalates to blocked_human).
3696
- reset_preserving_events(head_before, events_path)
3919
+ reset_preserving_events(head_before, events_path,
3920
+ untracked_before=untracked_before)
3697
3921
  # Redact any quoted leak-scan match before it lands
3698
3922
  # in events.jsonl / the attempt note, so the captured
3699
3923
  # FINDINGS text can't re-trip the next bookkeeping
@@ -3724,7 +3948,8 @@ def run(
3724
3948
  smoke_cmds, Path("."),
3725
3949
  )
3726
3950
  if not smoke_ok:
3727
- reset_preserving_events(head_before, events_path)
3951
+ reset_preserving_events(head_before, events_path,
3952
+ untracked_before=untracked_before)
3728
3953
  wu_events.append(emit_attempt_outcome(
3729
3954
  wu, attempt, "smoke_import_failed",
3730
3955
  attempts_usage[-1],
@@ -3741,7 +3966,8 @@ def run(
3741
3966
  wu, feature_dir, REPO_ROOT, head_before,
3742
3967
  )
3743
3968
  if not closing_ok:
3744
- reset_preserving_events(head_before, events_path)
3969
+ reset_preserving_events(head_before, events_path,
3970
+ untracked_before=untracked_before)
3745
3971
  # `assertion` names the primary (first) failing
3746
3972
  # assertion. With aggregation (#72) closing_summary
3747
3973
  # may list several; the first `assert_*` token is the
@@ -3777,7 +4003,8 @@ def run(
3777
4003
  # every current WU is unchanged.
3778
4004
  deliv_ok, deliv_summary = assert_declared_deliverables(wu)
3779
4005
  if not deliv_ok:
3780
- reset_preserving_events(head_before, events_path)
4006
+ reset_preserving_events(head_before, events_path,
4007
+ untracked_before=untracked_before)
3781
4008
  missing = deliv_summary.split(": ", 1)[-1]
3782
4009
  wu_events.append(emit_attempt_outcome(
3783
4010
  wu, attempt, "deliverable_missing",
@@ -3803,7 +4030,8 @@ def run(
3803
4030
  wu, touched,
3804
4031
  )
3805
4032
  if not impl_ok:
3806
- reset_preserving_events(head_before, events_path)
4033
+ reset_preserving_events(head_before, events_path,
4034
+ untracked_before=untracked_before)
3807
4035
  wu_events.append(emit_attempt_outcome(
3808
4036
  wu, attempt, "no_deliverable_files",
3809
4037
  attempts_usage[-1],
@@ -3905,7 +4133,8 @@ def run(
3905
4133
  ))
3906
4134
  attempt_notes.append((attempt, note))
3907
4135
  failure_note = note
3908
- reset_preserving_events(head_before, events_path)
4136
+ reset_preserving_events(head_before, events_path,
4137
+ untracked_before=untracked_before)
3909
4138
  print(f" FILES_CHANGED MISMATCH attempt "
3910
4139
  f"{attempt}/{MAX_ATTEMPTS} — {len(payload)} path(s) "
3911
4140
  f"unchanged")
@@ -3938,7 +4167,8 @@ def run(
3938
4167
  "attempts": attempt,
3939
4168
  "attempts_usage": attempts_usage,
3940
4169
  }))
3941
- reset_preserving_events(head_before, events_path)
4170
+ reset_preserving_events(head_before, events_path,
4171
+ untracked_before=untracked_before)
3942
4172
  backend.set_wu(wu, "status", "blocked_human")
3943
4173
  write_cost_to_wu(backend, wu, cum_usage)
3944
4174
  flush_events(events_path, wu_events)
@@ -3956,7 +4186,8 @@ def run(
3956
4186
  prior_failure_signature = (_fc, _fs)
3957
4187
  flush_events(events_path, wu_events)
3958
4188
  wu_events.clear()
3959
- reset_preserving_events(head_before, events_path)
4189
+ reset_preserving_events(head_before, events_path,
4190
+ untracked_before=untracked_before)
3960
4191
  print(f" FAIL attempt {attempt}/{MAX_ATTEMPTS}")
3961
4192
  else:
3962
4193
  # for-else: ran out of attempts without break = spinning.
@@ -4342,7 +4573,21 @@ def auto_sync(
4342
4573
  _persist_scaffold_sync(installed)
4343
4574
 
4344
4575
 
4576
+ def _force_utf8_console() -> None:
4577
+ """Force stdout/stderr to UTF-8 so the driver's non-ASCII console glyphs
4578
+ (↳, ═, ⚠, — …) don't crash on Windows, whose default console codepage is
4579
+ cp1252 (`UnicodeEncodeError: 'charmap' codec can't encode ...`). No-op on
4580
+ POSIX, which is already UTF-8. `reconfigure` exists on the standard
4581
+ text streams (Python ≥ 3.7); guard for the case where stdout has been
4582
+ replaced by a stream that lacks it (e.g. a captured buffer in tests)."""
4583
+ for stream in (sys.stdout, sys.stderr):
4584
+ reconfigure = getattr(stream, "reconfigure", None)
4585
+ if reconfigure is not None:
4586
+ reconfigure(encoding="utf-8")
4587
+
4588
+
4345
4589
  def main() -> int:
4590
+ _force_utf8_console()
4346
4591
  ap = argparse.ArgumentParser(description="Specfuse loop driver (single-repo).")
4347
4592
  ap.add_argument("--feature", help="Feature dir name under .specfuse/features/ "
4348
4593
  "(optional if exactly one feature is active).")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.14
3
+ Version: 0.3.16
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