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