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.
- {specfuse_loop-0.3.14/specfuse_loop.egg-info → specfuse_loop-0.3.15}/PKG-INFO +1 -1
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/pyproject.toml +1 -1
- specfuse_loop-0.3.15/specfuse/loop/_filelock.py +44 -0
- specfuse_loop-0.3.15/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/getting-started.md +10 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/loop.py +211 -34
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/SOURCES.txt +11 -0
- specfuse_loop-0.3.15/tests/test_autoclose_deferral_visibility.py +67 -0
- specfuse_loop-0.3.15/tests/test_bash_routing.py +110 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_bookkeeping_commit_hook_crash.py +29 -14
- specfuse_loop-0.3.15/tests/test_bookkeeping_no_verify.py +67 -0
- specfuse_loop-0.3.15/tests/test_claude_resolution.py +104 -0
- specfuse_loop-0.3.15/tests/test_console_encoding.py +70 -0
- specfuse_loop-0.3.15/tests/test_filelock_portable.py +98 -0
- specfuse_loop-0.3.15/tests/test_interpreter_normalization.py +122 -0
- specfuse_loop-0.3.15/tests/test_redaction_windows_home.py +54 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_squash_commit_hook_crash.py +31 -14
- specfuse_loop-0.3.15/tests/test_timeout_kill_portable.py +113 -0
- specfuse_loop-0.3.15/tests/test_windows_gate_exec.py +68 -0
- specfuse_loop-0.3.14/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/LICENSE +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/NOTICE +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/README.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/setup.cfg +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.14 → specfuse_loop-0.3.15}/tests/test_wu_execution_metadata.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specfuse-loop"
|
|
7
|
-
version = "0.3.
|
|
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.
|
|
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
|
|
475
|
-
#
|
|
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>/"
|
|
484
|
-
"/home/" + "<name>/"
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1216
|
+
"""Open .specfuse/.loop.lock and acquire a non-blocking exclusive lock.
|
|
1209
1217
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
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],
|
|
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],
|
|
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
|
|
1887
|
-
#
|
|
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
|
-
|
|
2026
|
+
popen_argv, stdin=subprocess.DEVNULL,
|
|
1891
2027
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
1892
|
-
|
|
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
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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).")
|
|
@@ -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()
|