specfuse-loop 0.3.14__tar.gz → 0.3.15__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 (161) hide show
  1. {specfuse_loop-0.3.14/specfuse_loop.egg-info → specfuse_loop-0.3.15}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.15/specfuse/loop/_filelock.py +44 -0
  4. specfuse_loop-0.3.15/specfuse/loop/data/VERSION +1 -0
  5. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/getting-started.md +10 -0
  6. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/loop.py +211 -34
  7. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15/specfuse_loop.egg-info}/PKG-INFO +1 -1
  8. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/SOURCES.txt +11 -0
  9. specfuse_loop-0.3.15/tests/test_autoclose_deferral_visibility.py +67 -0
  10. specfuse_loop-0.3.15/tests/test_bash_routing.py +110 -0
  11. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_bookkeeping_commit_hook_crash.py +29 -14
  12. specfuse_loop-0.3.15/tests/test_bookkeeping_no_verify.py +67 -0
  13. specfuse_loop-0.3.15/tests/test_claude_resolution.py +104 -0
  14. specfuse_loop-0.3.15/tests/test_console_encoding.py +70 -0
  15. specfuse_loop-0.3.15/tests/test_filelock_portable.py +98 -0
  16. specfuse_loop-0.3.15/tests/test_interpreter_normalization.py +122 -0
  17. specfuse_loop-0.3.15/tests/test_redaction_windows_home.py +54 -0
  18. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_squash_commit_hook_crash.py +31 -14
  19. specfuse_loop-0.3.15/tests/test_timeout_kill_portable.py +113 -0
  20. specfuse_loop-0.3.15/tests/test_windows_gate_exec.py +68 -0
  21. specfuse_loop-0.3.14/specfuse/loop/data/VERSION +0 -1
  22. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/LICENSE +0 -0
  23. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/NOTICE +0 -0
  24. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/README.md +0 -0
  25. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/setup.cfg +0 -0
  26. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/__init__.py +0 -0
  27. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/_miniyaml.py +0 -0
  28. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/adopt_feature.py +0 -0
  29. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  30. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  31. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  32. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/methodology.md +0 -0
  33. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/skills.md +0 -0
  34. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/gitignore.snippet +0 -0
  35. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/roadmap.template.md +0 -0
  36. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  37. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/never-touch.md +0 -0
  38. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/result-contract.md +0 -0
  39. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  40. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  41. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/event.schema.json +0 -0
  42. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  43. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  44. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  45. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  46. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/templates/GATE.template.md +0 -0
  47. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  48. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/templates/WU.template.md +0 -0
  49. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/verification.yml.example +0 -0
  50. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/gate_eval.py +0 -0
  51. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/gh_backend.py +0 -0
  52. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/gh_features.py +0 -0
  53. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/lint_plan.py +0 -0
  54. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/scaffold.py +0 -0
  55. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/validate_event.py +0 -0
  56. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  57. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/entry_points.txt +0 -0
  58. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/requires.txt +0 -0
  59. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/top_level.txt +0 -0
  60. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_adopt_feature.py +0 -0
  61. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  62. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_attempt_outcome_emission.py +0 -0
  63. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync.py +0 -0
  64. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync_consent.py +0 -0
  65. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync_firstrun.py +0 -0
  66. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync_plugin.py +0 -0
  67. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_backend.py +0 -0
  68. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  69. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_close_wu_self_reference.py +0 -0
  70. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_closing_deliverable_guard.py +0 -0
  71. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_cost_tracking.py +0 -0
  72. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_degraded_oracle.py +0 -0
  73. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_deliverable_presence_gate.py +0 -0
  74. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_doctor.py +0 -0
  75. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_driver_integration.py +0 -0
  76. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_driver_lock.py +0 -0
  77. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_duration_tracking.py +0 -0
  78. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_empty_files_escalation.py +0 -0
  79. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_ensure_feature_branch.py +0 -0
  80. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_ensure_feature_branch_base.py +0 -0
  81. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_events_redaction.py +0 -0
  82. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_extra_gates.py +0 -0
  83. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_force_full_close.py +0 -0
  84. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval.py +0 -0
  85. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval_calibration.py +0 -0
  86. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  87. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval_terminal_wiring.py +0 -0
  88. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gh_backend.py +0 -0
  89. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gh_features.py +0 -0
  90. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_git_env_isolation.py +0 -0
  91. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_hashed_denylist.py +0 -0
  92. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_hashed_denylist_ci.py +0 -0
  93. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_init_integration.py +0 -0
  94. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_leak_findings_redaction.py +0 -0
  95. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_leak_scan.py +0 -0
  96. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_leak_scan_content.py +0 -0
  97. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_learnings_query.py +0 -0
  98. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  99. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lifecycle_integration.py +0 -0
  100. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_bare_produces_path.py +0 -0
  101. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_close_intermediate.py +0 -0
  102. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_close_wu.py +0 -0
  103. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_correlation_id.py +0 -0
  104. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  105. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_oracle_env.py +0 -0
  106. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_plan_errors.py +0 -0
  107. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_plan_next_draft.py +0 -0
  108. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_plan_relative_import.py +0 -0
  109. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_produces_driver_helper.py +0 -0
  110. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_sections.py +0 -0
  111. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  112. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_auto_archive.py +0 -0
  113. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_caveman_preamble.py +0 -0
  114. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_close_intermediate.py +0 -0
  115. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_defaults_by_type.py +0 -0
  116. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_effort.py +0 -0
  117. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_failure_note_cap.py +0 -0
  118. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_files_changed_guard.py +0 -0
  119. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_gate_budget.py +0 -0
  120. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_model_alias.py +0 -0
  121. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_orchestration.py +0 -0
  122. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_post_pass_invariant.py +0 -0
  123. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_reset_preserving_events.py +0 -0
  124. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_smoke_runner.py +0 -0
  125. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_unsandboxed.py +0 -0
  126. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_zero_token_guard.py +0 -0
  127. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_migrate_legacy.py +0 -0
  128. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_miniyaml_equivalence.py +0 -0
  129. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_miniyaml_negative.py +0 -0
  130. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_planned_cost_lint.py +0 -0
  131. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_prepare_feature.py +0 -0
  132. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_prepare_scaffold_sync.py +0 -0
  133. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_produces_field.py +0 -0
  134. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_resolve_base.py +0 -0
  135. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_result_block.py +0 -0
  136. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_roadmap_add_skill.py +0 -0
  137. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_roadmap_archive_skill.py +0 -0
  138. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_roadmap_row_parser.py +0 -0
  139. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_data_in_sync.py +0 -0
  140. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_doc_hygiene.py +0 -0
  141. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_docs.py +0 -0
  142. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_init.py +0 -0
  143. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_manifest.py +0 -0
  144. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_resources.py +0 -0
  145. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_seed_sanity.py +0 -0
  146. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_upgrade.py +0 -0
  147. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_wiring.py +0 -0
  148. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_skills_vendored_in_sync.py +0 -0
  149. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_template_closing_shapes.py +0 -0
  150. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_terminal_flip_ownership.py +0 -0
  151. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_terminal_flips.py +0 -0
  152. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_untracked_feature_folder.py +0 -0
  153. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_untracked_file_absorption.py +0 -0
  154. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_upgrade_integration.py +0 -0
  155. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_upgrade_merge_gate.py +0 -0
  156. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_validate_event.py +0 -0
  157. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_verdict_coupling.py +0 -0
  158. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_verify_empty_gate_set.py +0 -0
  159. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_version_consistency.py +0 -0
  160. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_version_skew.py +0 -0
  161. {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/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.15
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.15"
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.15
@@ -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.15"
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(
@@ -1476,8 +1483,14 @@ def squash_commit(
1476
1483
  if not git("diff", "--cached", "--name-only"):
1477
1484
  return None
1478
1485
  msg = f"feat: {wu.title}\n\nFeature: {wu.wu_id}"
1486
+ # --no-verify: see commit_bookkeeping — the driver's WU squashes are gated
1487
+ # by `leak_scan.py --all` + CI, not the human-oriented structural pre-commit
1488
+ # hook (issue #156). Without this, an agent-authored fixture token the
1489
+ # structural scan flags (but `--all` allowlists) rejects the squash and the
1490
+ # driver blocks `spinning_detected` after 3 retries.
1479
1491
  res = subprocess.run(
1480
- ["git", "commit", "-m", msg], capture_output=True, text=True,
1492
+ ["git", "commit", "--no-verify", "-m", msg],
1493
+ capture_output=True, text=True,
1481
1494
  )
1482
1495
  if res.returncode != 0:
1483
1496
  raise SquashCommitError(
@@ -1599,6 +1612,7 @@ def dispatch(wu: WorkUnit, failure_note: str | None,
1599
1612
  + truncate_failure_note(failure_note))
1600
1613
  cmd = [p.replace("{model}", wu.model).replace("{effort}", wu.effort)
1601
1614
  for p in CLAUDE_CMD]
1615
+ cmd = resolve_claude_cmd(cmd)
1602
1616
  if wu.unsandboxed:
1603
1617
  # Per-WU sandbox-escape. Audited via the unsandboxed_dispatch event
1604
1618
  # emitted in run()'s attempt loop; rationale lives in WU frontmatter.
@@ -1717,6 +1731,39 @@ def extract_smoke_imports(wu_body: str) -> list[str]:
1717
1731
  return out
1718
1732
 
1719
1733
 
1734
+ LEADING_PYTHON3_RE = re.compile(r'^(\s*)python3\b')
1735
+
1736
+
1737
+ def resolve_windows_interpreter() -> str:
1738
+ """Return the interpreter token to substitute for a leading `python3`.
1739
+
1740
+ Windows Python ships `python` and the `py` launcher, never `python3`. A
1741
+ bare `python` token is the safest form to hand to Git-Bash `bash -c` —
1742
+ unlike a raw `sys.executable` path, it has no backslashes for MSYS to
1743
+ mangle (cross-repo contract, see GATE-02-REVIEW.md). No real-Windows-runner
1744
+ round-trip has verified this from the sandbox; T08 owns that proof.
1745
+ """
1746
+ return "python"
1747
+
1748
+
1749
+ def normalize_interpreter(command: str) -> str:
1750
+ """Rewrite a leading `python3` token to the Windows interpreter, else no-op.
1751
+
1752
+ POSIX (`sys.platform != "win32"`): returns `command` unchanged — target
1753
+ repos and the driver's own smoke imports already assume `python3` exists.
1754
+ Windows: rewrites ONLY a `python3` token anchored at the start of the
1755
+ command string (optionally after leading whitespace); a `python3`
1756
+ appearing later, e.g. inside a quoted argument, is left untouched.
1757
+ """
1758
+ if sys.platform != "win32":
1759
+ return command
1760
+ match = LEADING_PYTHON3_RE.match(command)
1761
+ if not match:
1762
+ return command
1763
+ interpreter = resolve_windows_interpreter()
1764
+ return match.group(1) + interpreter + command[match.end():]
1765
+
1766
+
1720
1767
  def run_smoke_imports(commands: list[str], cwd: Path) -> tuple[bool, str]:
1721
1768
  """Run each smoke-import command in `cwd` in declared order.
1722
1769
 
@@ -1731,6 +1778,7 @@ def run_smoke_imports(commands: list[str], cwd: Path) -> tuple[bool, str]:
1731
1778
  active venv per `[loop-driver-operation]`).
1732
1779
  """
1733
1780
  for cmd in commands:
1781
+ cmd = normalize_interpreter(cmd)
1734
1782
  proc = subprocess.run( # nosec B602
1735
1783
  cmd, shell=True, capture_output=True, text=True, cwd=str(cwd),
1736
1784
  )
@@ -1827,6 +1875,65 @@ def load_verification() -> dict:
1827
1875
  return _miniyaml.parse(VERIFICATION_PATH.read_text()) or {}
1828
1876
 
1829
1877
 
1878
+ def resolve_claude_cmd(cmd: list[str]) -> list[str]:
1879
+ """Resolve a bare `claude` argv[0] to its executable path on Windows.
1880
+
1881
+ `subprocess.run(..., shell=False)` calls `CreateProcess` on Windows, which
1882
+ does not consult `PATHEXT` — a bare `"claude"` argv[0] will not resolve to
1883
+ the `claude.cmd` shim the Windows install ships. `shutil.which` does honor
1884
+ `PATHEXT`, so use it to find the real executable and substitute it as
1885
+ argv[0]; the rest of `cmd` (flags, model/effort substitution) is untouched.
1886
+ On POSIX, `shell=False` with a bare `claude` already resolves via PATH, so
1887
+ `cmd` is returned unchanged.
1888
+ """
1889
+ if sys.platform != "win32":
1890
+ return cmd
1891
+ resolved = shutil.which(cmd[0])
1892
+ if not resolved:
1893
+ raise SystemExit(
1894
+ f"claude not found on PATH — install Claude Code or add its "
1895
+ f"install directory to PATH (resolve_claude_cmd: "
1896
+ f"shutil.which('{cmd[0]}') returned None)."
1897
+ )
1898
+ return [resolved] + cmd[1:]
1899
+
1900
+
1901
+ def resolve_bash() -> str | None:
1902
+ """Resolve a Git-for-Windows `bash.exe` for routing gate commands.
1903
+
1904
+ Prefers deriving it from `git --exec-path` (the git-core dir inside a
1905
+ Git-for-Windows install; `bash.exe` lives at the install's `bin/bash.exe`,
1906
+ i.e. two levels up from git-core then into `bin`) over a bare
1907
+ `shutil.which("bash")`, which on a Windows host can resolve to
1908
+ `C:\\Windows\\System32\\bash.exe` — the WSL launcher, which fails with no
1909
+ distro installed. Returns None if no Git-Bash can be found.
1910
+ """
1911
+ try:
1912
+ completed = subprocess.run(
1913
+ ["git", "--exec-path"], capture_output=True, text=True, check=True,
1914
+ )
1915
+ exec_path = str(completed.stdout).strip()
1916
+ if exec_path:
1917
+ candidate = Path(exec_path).parent.parent / "bin" / "bash.exe"
1918
+ if candidate.is_file():
1919
+ return str(candidate)
1920
+ except Exception:
1921
+ pass
1922
+
1923
+ # Manual PATH scan rather than shutil.which(): shutil.which's win32 branch
1924
+ # touches _winapi, which is unavailable when running under a mocked
1925
+ # sys.platform on a non-Windows test host.
1926
+ for dirname in os.environ.get("PATH", "").split(os.pathsep):
1927
+ if not dirname:
1928
+ continue
1929
+ for name in ("bash.exe", "bash"):
1930
+ candidate = Path(dirname) / name
1931
+ if candidate.is_file() and "system32" not in str(candidate).lower():
1932
+ return str(candidate)
1933
+
1934
+ return None
1935
+
1936
+
1830
1937
  def verify(wu: WorkUnit, feature_dir: Path,
1831
1938
  cfg: dict | None = None) -> tuple[bool, str]:
1832
1939
  """Driver runs the gates itself — the exit oracle. Agent self-report is advisory.
@@ -1873,6 +1980,7 @@ def verify(wu: WorkUnit, feature_dir: Path,
1873
1980
  results, ok_all = [], True
1874
1981
  for gate in gate_set:
1875
1982
  command = gate["command"].replace("{feature_dir}", str(feature_dir))
1983
+ command = normalize_interpreter(command)
1876
1984
  # shell=True is intentional: gate commands are authored by the user in
1877
1985
  # verification.yml and routinely use shell features (pipes, &&, glob,
1878
1986
  # redirects — e.g. `dotnet build && dotnet test --no-build`). The input
@@ -1883,13 +1991,41 @@ def verify(wu: WorkUnit, feature_dir: Path,
1883
1991
  # output pipe so communicate() stalls past the timeout):
1884
1992
  # 1. stdin=DEVNULL — a gate that reads stdin (e.g. a test calling input())
1885
1993
  # 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
1994
+ # 2. start_new_session + killpg (POSIX) / CREATE_NEW_PROCESS_GROUP +
1995
+ # taskkill (Windows) on timeout the gate runs in its own process
1996
+ # group; on timeout the WHOLE group (shell + grandchildren) is
1888
1997
  # killed, so the timer actually returns.
1998
+ is_win32 = sys.platform == "win32"
1999
+ spawn_kwargs = (
2000
+ {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)}
2001
+ if is_win32
2002
+ else {"start_new_session": True}
2003
+ )
2004
+ if is_win32:
2005
+ # cmd.exe (the shell=True default on Windows) does not understand
2006
+ # the POSIX shell syntax (&&, ||, globs, pipes) that real
2007
+ # verification.yml gate commands routinely use. Route through
2008
+ # Git-Bash instead so gate commands run unmodified across
2009
+ # platforms.
2010
+ bash = resolve_bash()
2011
+ if not bash:
2012
+ ok_all = False
2013
+ results.append(
2014
+ f"### {gate['name']}: FAIL\n```\n$ {command}\n"
2015
+ f"No Git-Bash found — install Git for Windows "
2016
+ f"(https://git-scm.com/download/win) so gate commands can "
2017
+ f"run through bash.exe.\n```"
2018
+ )
2019
+ continue
2020
+ popen_argv = [bash, "-c", command]
2021
+ popen_kwargs = dict(shell=False)
2022
+ else:
2023
+ popen_argv = command
2024
+ popen_kwargs = dict(shell=True) # nosec B604
1889
2025
  proc = subprocess.Popen( # nosec B602
1890
- command, shell=True, stdin=subprocess.DEVNULL,
2026
+ popen_argv, stdin=subprocess.DEVNULL,
1891
2027
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
1892
- start_new_session=True,
2028
+ **popen_kwargs, **spawn_kwargs,
1893
2029
  )
1894
2030
  try:
1895
2031
  out, _ = proc.communicate(timeout=GATE_TIMEOUT_SECONDS)
@@ -1904,10 +2040,16 @@ def verify(wu: WorkUnit, feature_dir: Path,
1904
2040
  ok = False
1905
2041
  tail = tail + [f"DEGRADED ORACLE: {degraded}"]
1906
2042
  except subprocess.TimeoutExpired:
1907
- try:
1908
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
1909
- except (ProcessLookupError, PermissionError):
1910
- proc.kill()
2043
+ if is_win32:
2044
+ subprocess.run(
2045
+ ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
2046
+ capture_output=True,
2047
+ )
2048
+ else:
2049
+ try:
2050
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
2051
+ except (ProcessLookupError, PermissionError):
2052
+ proc.kill()
1911
2053
  out, _ = proc.communicate()
1912
2054
  ok = False
1913
2055
  tail = (out or "").strip().splitlines()[-10:] + [
@@ -2400,7 +2542,17 @@ def write_stub_retrospective_terminal(
2400
2542
  f"- predicate_version: {decision.predicate_version}\n"
2401
2543
  f"- gate_total_cost: ${total_cost:.2f}\n"
2402
2544
  f"- gate_budget: {budget_str}\n"
2403
- f"- reasons: [] (auto=True)\n"
2545
+ f"- reasons: [] (auto=True)\n\n"
2546
+ # issue #157: terminal auto-close skips the close body AND has no
2547
+ # downstream gate to catch the gap — the operator is the last line.
2548
+ f"## What the loop did NOT verify (gate {gate_number})\n\n"
2549
+ f"This terminal gate auto-closed on-plan; the full close ceremony did not\n"
2550
+ f"run, so the per-criterion deferred-verification list was **not**\n"
2551
+ f"enumerated, and there is no downstream gate to reconcile it. Before\n"
2552
+ f"treating the feature as fully verified, the operator MUST confirm every\n"
2553
+ f"acceptance criterion was actually verified in-loop (not only by artifact\n"
2554
+ f"shape). Any AC deferred to a post-merge or real-system step must be\n"
2555
+ f"recorded and completed now.\n"
2404
2556
  )
2405
2557
  if retro.exists():
2406
2558
  with retro.open("a") as fh:
@@ -2528,7 +2680,18 @@ def append_stub_retrospective_intermediate(
2528
2680
  f"- predicate_version: {decision.predicate_version}\n"
2529
2681
  f"- gate_total_cost: ${total_cost:.2f}\n"
2530
2682
  f"- gate_budget: {budget_str}\n"
2531
- f"- reasons: [] (auto=True)\n"
2683
+ f"- reasons: [] (auto=True)\n\n"
2684
+ # issue #157: the auto-close path skips the close-intermediate body, so
2685
+ # the per-criterion deferred-verification list is never enumerated. Emit
2686
+ # an explicit marker rather than silently omitting it — the predicate
2687
+ # cannot reason over ACs, so it flags the gap for downstream reconciliation.
2688
+ f"## What the loop did NOT verify (gate {gate_number})\n\n"
2689
+ f"This gate auto-closed on-plan; the full close-intermediate ceremony did\n"
2690
+ f"not run, so the per-criterion deferred-verification list was **not**\n"
2691
+ f"enumerated. Any acceptance criterion whose verification is deferred\n"
2692
+ f"(loop-sandbox limit, cross-repo coordination, real-system access) is\n"
2693
+ f"unrecorded here. Gate {gate_number + 1}'s close MUST reconcile these\n"
2694
+ f"before the feature's terminal verdict — auto-close cannot enumerate them.\n"
2532
2695
  )
2533
2696
  if retro.exists():
2534
2697
  with retro.open("a") as fh:
@@ -4342,7 +4505,21 @@ def auto_sync(
4342
4505
  _persist_scaffold_sync(installed)
4343
4506
 
4344
4507
 
4508
+ def _force_utf8_console() -> None:
4509
+ """Force stdout/stderr to UTF-8 so the driver's non-ASCII console glyphs
4510
+ (↳, ═, ⚠, — …) don't crash on Windows, whose default console codepage is
4511
+ cp1252 (`UnicodeEncodeError: 'charmap' codec can't encode ...`). No-op on
4512
+ POSIX, which is already UTF-8. `reconfigure` exists on the standard
4513
+ text streams (Python ≥ 3.7); guard for the case where stdout has been
4514
+ replaced by a stream that lacks it (e.g. a captured buffer in tests)."""
4515
+ for stream in (sys.stdout, sys.stderr):
4516
+ reconfigure = getattr(stream, "reconfigure", None)
4517
+ if reconfigure is not None:
4518
+ reconfigure(encoding="utf-8")
4519
+
4520
+
4345
4521
  def main() -> int:
4522
+ _force_utf8_console()
4346
4523
  ap = argparse.ArgumentParser(description="Specfuse loop driver (single-repo).")
4347
4524
  ap.add_argument("--feature", help="Feature dir name under .specfuse/features/ "
4348
4525
  "(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.15
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
@@ -3,6 +3,7 @@ NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
5
  specfuse/loop/__init__.py
6
+ specfuse/loop/_filelock.py
6
7
  specfuse/loop/_miniyaml.py
7
8
  specfuse/loop/adopt_feature.py
8
9
  specfuse/loop/gate_eval.py
@@ -44,15 +45,20 @@ specfuse_loop.egg-info/top_level.txt
44
45
  tests/test_adopt_feature.py
45
46
  tests/test_arm_gate_edits_uncommitted.py
46
47
  tests/test_attempt_outcome_emission.py
48
+ tests/test_autoclose_deferral_visibility.py
47
49
  tests/test_autosync.py
48
50
  tests/test_autosync_consent.py
49
51
  tests/test_autosync_firstrun.py
50
52
  tests/test_autosync_plugin.py
51
53
  tests/test_backend.py
54
+ tests/test_bash_routing.py
52
55
  tests/test_bookkeeping_commit_crash_run.py
53
56
  tests/test_bookkeeping_commit_hook_crash.py
57
+ tests/test_bookkeeping_no_verify.py
58
+ tests/test_claude_resolution.py
54
59
  tests/test_close_wu_self_reference.py
55
60
  tests/test_closing_deliverable_guard.py
61
+ tests/test_console_encoding.py
56
62
  tests/test_cost_tracking.py
57
63
  tests/test_degraded_oracle.py
58
64
  tests/test_deliverable_presence_gate.py
@@ -65,6 +71,7 @@ tests/test_ensure_feature_branch.py
65
71
  tests/test_ensure_feature_branch_base.py
66
72
  tests/test_events_redaction.py
67
73
  tests/test_extra_gates.py
74
+ tests/test_filelock_portable.py
68
75
  tests/test_force_full_close.py
69
76
  tests/test_gate_eval.py
70
77
  tests/test_gate_eval_calibration.py
@@ -76,6 +83,7 @@ tests/test_git_env_isolation.py
76
83
  tests/test_hashed_denylist.py
77
84
  tests/test_hashed_denylist_ci.py
78
85
  tests/test_init_integration.py
86
+ tests/test_interpreter_normalization.py
79
87
  tests/test_leak_findings_redaction.py
80
88
  tests/test_leak_scan.py
81
89
  tests/test_leak_scan_content.py
@@ -116,6 +124,7 @@ tests/test_planned_cost_lint.py
116
124
  tests/test_prepare_feature.py
117
125
  tests/test_prepare_scaffold_sync.py
118
126
  tests/test_produces_field.py
127
+ tests/test_redaction_windows_home.py
119
128
  tests/test_resolve_base.py
120
129
  tests/test_result_block.py
121
130
  tests/test_roadmap_add_skill.py
@@ -135,6 +144,7 @@ tests/test_squash_commit_hook_crash.py
135
144
  tests/test_template_closing_shapes.py
136
145
  tests/test_terminal_flip_ownership.py
137
146
  tests/test_terminal_flips.py
147
+ tests/test_timeout_kill_portable.py
138
148
  tests/test_untracked_feature_folder.py
139
149
  tests/test_untracked_file_absorption.py
140
150
  tests/test_upgrade_integration.py
@@ -144,4 +154,5 @@ tests/test_verdict_coupling.py
144
154
  tests/test_verify_empty_gate_set.py
145
155
  tests/test_version_consistency.py
146
156
  tests/test_version_skew.py
157
+ tests/test_windows_gate_exec.py
147
158
  tests/test_wu_execution_metadata.py
@@ -0,0 +1,67 @@
1
+ # Copyright 2026 Specfuse contributors
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
3
+ #
4
+ # Issue #157: the auto-close path marks a close/close-intermediate WU done
5
+ # without dispatching its body, so the mandatory "What the loop did NOT verify"
6
+ # deferral list is never written. The auto-close stub writers must instead emit
7
+ # an explicit deferral-visibility section so the gap is surfaced (direction 2),
8
+ # not silently omitted — for BOTH the intermediate and terminal stubs.
9
+
10
+ import tempfile
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ from tests._loop_loader import load_loop
15
+
16
+ loop = load_loop()
17
+ from specfuse.loop.gate_eval import AutoCloseDecision # noqa: E402
18
+
19
+
20
+ def _decision(gate: int) -> AutoCloseDecision:
21
+ return AutoCloseDecision(
22
+ auto=True,
23
+ reasons=[],
24
+ metrics={"gate_total_cost": 1.23, "gate_budget": 5.0},
25
+ gate_id=gate,
26
+ feature_id="FEAT-TEST-0001",
27
+ predicate_version="v1",
28
+ )
29
+
30
+
31
+ class TestAutoCloseDeferralVisibility(unittest.TestCase):
32
+ def setUp(self):
33
+ self._tmp = tempfile.TemporaryDirectory()
34
+ self.fd = Path(self._tmp.name)
35
+
36
+ def tearDown(self):
37
+ self._tmp.cleanup()
38
+
39
+ def _retro(self) -> str:
40
+ return (self.fd / "RETROSPECTIVE.md").read_text()
41
+
42
+ def test_intermediate_stub_flags_deferred_verification_gap(self):
43
+ """The intermediate auto-close stub emits a 'What the loop did NOT
44
+ verify' section pointing reconciliation at the next gate's close."""
45
+ loop.append_stub_retrospective_intermediate(self.fd, 1, _decision(1))
46
+ retro = self._retro()
47
+ self.assertIn("## What the loop did NOT verify", retro)
48
+ self.assertIn("Gate 2's close", retro) # next-gate reconciliation
49
+
50
+ def test_terminal_stub_flags_deferred_verification_gap(self):
51
+ """The terminal auto-close stub emits a 'What the loop did NOT verify'
52
+ section pointing reconciliation at the operator (no downstream gate)."""
53
+ loop.write_stub_retrospective_terminal(self.fd, 2, _decision(2))
54
+ retro = self._retro()
55
+ self.assertIn("## What the loop did NOT verify", retro)
56
+ self.assertIn("operator", retro.lower())
57
+
58
+ def test_intermediate_stub_still_records_cost_and_gate_heading(self):
59
+ """Existing stub content is preserved (cost, gate heading)."""
60
+ loop.append_stub_retrospective_intermediate(self.fd, 1, _decision(1))
61
+ retro = self._retro()
62
+ self.assertIn("## Gate 1 — auto-closed", retro)
63
+ self.assertIn("gate_total_cost: $1.23", retro)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ unittest.main()