specfuse-loop 0.3.12__tar.gz → 0.3.14__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.12/specfuse_loop.egg-info → specfuse_loop-0.3.14}/PKG-INFO +1 -1
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/pyproject.toml +1 -1
- specfuse_loop-0.3.14/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/gh_backend.py +11 -6
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/lint_plan.py +9 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/loop.py +182 -31
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/SOURCES.txt +4 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_backend.py +6 -1
- specfuse_loop-0.3.14/tests/test_close_wu_self_reference.py +115 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_cost_tracking.py +6 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_driver_integration.py +18 -1
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_duration_tracking.py +6 -0
- specfuse_loop-0.3.14/tests/test_ensure_feature_branch_base.py +159 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gh_backend.py +57 -14
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_plan_errors.py +41 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_files_changed_guard.py +8 -1
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_orchestration.py +6 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_zero_token_guard.py +6 -1
- specfuse_loop-0.3.14/tests/test_resolve_base.py +180 -0
- specfuse_loop-0.3.14/tests/test_untracked_file_absorption.py +218 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_wu_execution_metadata.py +6 -0
- specfuse_loop-0.3.12/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/LICENSE +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/NOTICE +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/README.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/setup.cfg +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_version_skew.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.14"
|
|
8
8
|
description = "Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.14
|
|
@@ -13,9 +13,14 @@ from typing import Callable, Optional
|
|
|
13
13
|
from . import loop as _loop
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def _default_runner(args: list
|
|
17
|
-
"""Shell out to gh with the given argument list. Not called in tests.
|
|
18
|
-
|
|
16
|
+
def _default_runner(args: list, check: bool = True):
|
|
17
|
+
"""Shell out to gh with the given argument list. Not called in tests.
|
|
18
|
+
|
|
19
|
+
Returns the completed process so probing callers (e.g. the `gh pr view`
|
|
20
|
+
idempotency check) can inspect `.returncode` without raising; side-effecting
|
|
21
|
+
callers pass no `check` override and get the prior raise-on-failure behavior.
|
|
22
|
+
"""
|
|
23
|
+
return subprocess.run(args, check=check, capture_output=True, text=True)
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
class GitHubBackend(_loop.Backend):
|
|
@@ -55,9 +60,9 @@ class GitHubBackend(_loop.Backend):
|
|
|
55
60
|
title = feat_fm.get("title", feature_id)
|
|
56
61
|
|
|
57
62
|
# Idempotent: skip PR creation if one already exists for this branch.
|
|
58
|
-
check =
|
|
63
|
+
check = self._runner(
|
|
59
64
|
["gh", "pr", "view", branch, "--repo", self.repo, "--json", "number"],
|
|
60
|
-
|
|
65
|
+
check=False,
|
|
61
66
|
)
|
|
62
67
|
if check.returncode != 0:
|
|
63
68
|
body = (
|
|
@@ -70,7 +75,7 @@ class GitHubBackend(_loop.Backend):
|
|
|
70
75
|
"gh", "pr", "create",
|
|
71
76
|
"--title", f"[{feature_id}] {title}",
|
|
72
77
|
"--body", body,
|
|
73
|
-
"--base",
|
|
78
|
+
"--base", _loop.resolve_base(feat_fm),
|
|
74
79
|
"--head", branch,
|
|
75
80
|
])
|
|
76
81
|
|
|
@@ -249,6 +249,15 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
249
249
|
if missing:
|
|
250
250
|
errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
|
|
251
251
|
|
|
252
|
+
if "base" in fm:
|
|
253
|
+
base_val = fm["base"]
|
|
254
|
+
feature_id_val = fm.get("feature_id", feature_dir.name)
|
|
255
|
+
if not isinstance(base_val, str) or not base_val.strip():
|
|
256
|
+
errs.append(
|
|
257
|
+
f"{feature_id_val}: PLAN.md frontmatter 'base' key is present but "
|
|
258
|
+
f"empty/whitespace-only/non-string: {base_val!r}"
|
|
259
|
+
)
|
|
260
|
+
|
|
252
261
|
graph = _find_task_graph_block(body)
|
|
253
262
|
if graph is None:
|
|
254
263
|
return errs + ["PLAN.md has no ```yaml graph block"]
|
|
@@ -62,7 +62,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
62
62
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
63
63
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
64
64
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
65
|
-
DRIVER_VERSION = "0.3.
|
|
65
|
+
DRIVER_VERSION = "0.3.14"
|
|
66
66
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
67
67
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
68
68
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -719,14 +719,34 @@ class FeatureBranchError(RuntimeError):
|
|
|
719
719
|
"""
|
|
720
720
|
|
|
721
721
|
|
|
722
|
+
def untracked_paths() -> set[str]:
|
|
723
|
+
"""Repo-relative paths git reports as untracked, honoring .gitignore.
|
|
724
|
+
|
|
725
|
+
Snapshotted per-WU immediately before dispatch so `squash_commit` can tell
|
|
726
|
+
"the operator already had this file" from "this run created it" — only the
|
|
727
|
+
latter belongs in the WU's commit. See issue #150 and `squash_commit`.
|
|
728
|
+
"""
|
|
729
|
+
out = subprocess.run(
|
|
730
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
731
|
+
capture_output=True, text=True, check=True,
|
|
732
|
+
).stdout
|
|
733
|
+
return {line.strip() for line in out.splitlines() if line.strip()}
|
|
734
|
+
|
|
735
|
+
|
|
722
736
|
def _tracked_dirty_paths() -> set[str]:
|
|
723
737
|
"""Paths with TRACKED, uncommitted changes (staged or unstaged), repo-relative.
|
|
724
738
|
|
|
725
739
|
Untracked files (porcelain `??`) are excluded: they never block a create
|
|
726
|
-
(`checkout -B`)
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
740
|
+
(`checkout -B`), so counting them would spuriously flag a leftover
|
|
741
|
+
events.jsonl as an "unexpected" change. The dirty-tree failure in #48 is
|
|
742
|
+
tracked local modifications ("your local changes would be overwritten by
|
|
743
|
+
checkout"), which is exactly what this set captures.
|
|
744
|
+
|
|
745
|
+
This exclusion means untracked files DO carry onto a freshly created feature
|
|
746
|
+
branch. That is safe only because `squash_commit` refuses to stage untracked
|
|
747
|
+
paths that pre-date the WU's dispatch (#150) — before that fix, its
|
|
748
|
+
`git add -A` committed them, so this guard was strictly narrower than the
|
|
749
|
+
commit that followed it.
|
|
730
750
|
"""
|
|
731
751
|
out = subprocess.run(
|
|
732
752
|
["git", "status", "--porcelain"],
|
|
@@ -790,6 +810,78 @@ def _default_branch() -> "str | None":
|
|
|
790
810
|
return None
|
|
791
811
|
|
|
792
812
|
|
|
813
|
+
def resolve_base(feat_fm: dict) -> "str | None":
|
|
814
|
+
"""The ref a feature's branch/PR should sit on top of.
|
|
815
|
+
|
|
816
|
+
Frontmatter `base` wins when set and non-empty (after stripping
|
|
817
|
+
whitespace). Absent, empty, or whitespace-only falls back to
|
|
818
|
+
`_default_branch()`; if that too is undeterminable, falls back to the
|
|
819
|
+
current branch. Callers (T02, T03) are responsible for wiring this in —
|
|
820
|
+
this function only resolves the name, it does not touch git state.
|
|
821
|
+
"""
|
|
822
|
+
base = feat_fm.get("base")
|
|
823
|
+
if isinstance(base, str) and base.strip():
|
|
824
|
+
return base.strip()
|
|
825
|
+
default = _default_branch()
|
|
826
|
+
if default:
|
|
827
|
+
return default
|
|
828
|
+
return _current_branch()
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class BaseBranchError(RuntimeError):
|
|
832
|
+
"""Raised when a feature's declared `base` ref cannot be made available.
|
|
833
|
+
|
|
834
|
+
Carries an actionable, human-readable message distinguishing a probable
|
|
835
|
+
typo (remote confirms the ref does not exist) from an unreachable remote
|
|
836
|
+
(network/auth failure — the ref's existence is simply unknown), so the two
|
|
837
|
+
cases are never mistaken for each other.
|
|
838
|
+
"""
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def ensure_base_ref(base: str) -> None:
|
|
842
|
+
"""Make sure git ref *base* is resolvable locally, fetching it if needed.
|
|
843
|
+
|
|
844
|
+
No-ops (no network call) when `git rev-parse --verify base` already
|
|
845
|
+
succeeds locally. Otherwise asks origin via `git ls-remote --exit-code`:
|
|
846
|
+
if origin has it, fetches it and prints one line naming what and why; if
|
|
847
|
+
origin explicitly does not have it, raises BaseBranchError naming likely
|
|
848
|
+
local-branch candidates (probable typo); if ls-remote itself fails
|
|
849
|
+
(offline/auth), raises a distinctly-worded BaseBranchError instead of
|
|
850
|
+
conflating "does not exist" with "could not check".
|
|
851
|
+
"""
|
|
852
|
+
local = subprocess.run(
|
|
853
|
+
["git", "rev-parse", "--verify", base],
|
|
854
|
+
capture_output=True, text=True,
|
|
855
|
+
)
|
|
856
|
+
if local.returncode == 0:
|
|
857
|
+
return
|
|
858
|
+
remote = subprocess.run(
|
|
859
|
+
["git", "ls-remote", "--exit-code", "origin", base],
|
|
860
|
+
capture_output=True, text=True,
|
|
861
|
+
)
|
|
862
|
+
if remote.returncode == 0:
|
|
863
|
+
subprocess.run(["git", "fetch", "origin", base], capture_output=True, text=True, check=True)
|
|
864
|
+
print(f"ensure_base_ref: fetched '{base}' from origin (declared feature base, not present locally).")
|
|
865
|
+
return
|
|
866
|
+
if remote.returncode == 2:
|
|
867
|
+
# ls-remote's documented exit code for "ref not found on that remote".
|
|
868
|
+
branches = subprocess.run(
|
|
869
|
+
["git", "branch", "--list", "--format=%(refname:short)"],
|
|
870
|
+
capture_output=True, text=True,
|
|
871
|
+
).stdout.splitlines()
|
|
872
|
+
candidates = [b.strip() for b in branches if b.strip()]
|
|
873
|
+
listed = ", ".join(sorted(candidates)) if candidates else "(no local branches)"
|
|
874
|
+
raise BaseBranchError(
|
|
875
|
+
f"base '{base}' does not exist locally or on origin — probable typo. "
|
|
876
|
+
f"Local branches: {listed}"
|
|
877
|
+
)
|
|
878
|
+
stderr = remote.stderr.strip() or remote.stdout.strip() or "(no git output)"
|
|
879
|
+
raise BaseBranchError(
|
|
880
|
+
f"base '{base}' could not be verified against origin — remote unreachable "
|
|
881
|
+
f"(offline or auth failure), not confirmed missing: {stderr}"
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
793
885
|
# Paths the scaffold overlay owns — auto_sync writes these on upgrade. They are
|
|
794
886
|
# the driver's, not the user's edits: they may carry onto a feature branch and
|
|
795
887
|
# are folded into the --prepare scaffold commit. Mirrors scaffold.py's versioned
|
|
@@ -1059,34 +1151,41 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
|
|
|
1059
1151
|
).stdout.strip()
|
|
1060
1152
|
if current == branch:
|
|
1061
1153
|
return
|
|
1154
|
+
# A declared `base` (T01's resolve_base/ensure_base_ref) is the branch's
|
|
1155
|
+
# true starting point, not wherever HEAD happens to be standing. Resolving
|
|
1156
|
+
# and fetching it here — before any checkout — lets both branch creation
|
|
1157
|
+
# and the staleness check below anchor on the base instead of HEAD.
|
|
1158
|
+
# BaseBranchError is intentionally left to propagate uncaught: the base
|
|
1159
|
+
# cause must reach the operator, not be reshaped into a FeatureBranchError.
|
|
1160
|
+
base = resolve_base(feat_fm)
|
|
1161
|
+
if base:
|
|
1162
|
+
ensure_base_ref(base)
|
|
1163
|
+
else:
|
|
1164
|
+
base = current
|
|
1062
1165
|
exists = subprocess.run(
|
|
1063
1166
|
["git", "rev-parse", "--verify", branch],
|
|
1064
1167
|
capture_output=True, text=True,
|
|
1065
1168
|
).returncode == 0
|
|
1066
1169
|
if exists:
|
|
1067
|
-
# Surface a stale branch that diverged from the
|
|
1068
|
-
# silently reusing it. `merge-base --is-ancestor B
|
|
1069
|
-
# is an ancestor of
|
|
1170
|
+
# Surface a stale branch that diverged from the declared base instead
|
|
1171
|
+
# of silently reusing it. `merge-base --is-ancestor B <base>` exits 0
|
|
1172
|
+
# iff B is an ancestor of base (i.e. base already contains B — safe).
|
|
1070
1173
|
is_ancestor = subprocess.run(
|
|
1071
|
-
["git", "merge-base", "--is-ancestor", branch,
|
|
1174
|
+
["git", "merge-base", "--is-ancestor", branch, base],
|
|
1072
1175
|
capture_output=True, text=True,
|
|
1073
1176
|
).returncode == 0
|
|
1074
1177
|
if not is_ancestor:
|
|
1075
1178
|
raise FeatureBranchError(
|
|
1076
|
-
f"branch '{branch}'
|
|
1077
|
-
f"
|
|
1078
|
-
f"
|
|
1079
|
-
f"
|
|
1080
|
-
f"
|
|
1081
|
-
f" - rebase it onto here: git checkout {branch} && "
|
|
1082
|
-
f"git rebase {current}\n"
|
|
1083
|
-
f" - discard it (work not needed): git branch -D {branch} "
|
|
1084
|
-
f"(the loop recreates it from HEAD)"
|
|
1179
|
+
f"branch '{branch}' has diverged from '{base}' — it carries "
|
|
1180
|
+
f"commits '{base}' does not, likely because it was created "
|
|
1181
|
+
f"from a different starting point or '{base}' has since moved "
|
|
1182
|
+
f"on. The safe action is to bring it up to date with the base:\n"
|
|
1183
|
+
f" git checkout {branch} && git rebase {base}"
|
|
1085
1184
|
)
|
|
1086
1185
|
_checked_checkout(["checkout", branch], f"checkout of existing branch '{branch}'")
|
|
1087
1186
|
print(f"Switched to feature branch '{branch}' (was on '{current}').")
|
|
1088
1187
|
else:
|
|
1089
|
-
# Create-from-
|
|
1188
|
+
# Create-from-base carries the working tree onto the new branch. Only
|
|
1090
1189
|
# the expected /pick-feature flips may ride along; anything else stops.
|
|
1091
1190
|
dirty = _tracked_dirty_paths()
|
|
1092
1191
|
# Scaffold-overlay files (auto_sync's own upgrade writes) ride along too:
|
|
@@ -1101,8 +1200,8 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
|
|
|
1101
1200
|
+ f". Refusing to carry them onto new branch '{branch}'. "
|
|
1102
1201
|
"Commit or stash them first, then re-run."
|
|
1103
1202
|
)
|
|
1104
|
-
_checked_checkout(["checkout", "-B", branch], f"create of branch '{branch}'")
|
|
1105
|
-
print(f"Created feature branch '{branch}' from '{
|
|
1203
|
+
_checked_checkout(["checkout", "-B", branch, base], f"create of branch '{branch}'")
|
|
1204
|
+
print(f"Created feature branch '{branch}' from '{base}'.")
|
|
1106
1205
|
|
|
1107
1206
|
|
|
1108
1207
|
def acquire_tree_lock(specfuse_dir: Path):
|
|
@@ -1344,12 +1443,38 @@ class SquashCommitError(RuntimeError):
|
|
|
1344
1443
|
"""
|
|
1345
1444
|
|
|
1346
1445
|
|
|
1347
|
-
def squash_commit(
|
|
1446
|
+
def squash_commit(
|
|
1447
|
+
wu: WorkUnit,
|
|
1448
|
+
head_before: str,
|
|
1449
|
+
untracked_before: "set[str] | frozenset[str]" = frozenset(),
|
|
1450
|
+
) -> str | None:
|
|
1451
|
+
"""Squash the WU's work into one commit; return its sha, or None if nothing.
|
|
1452
|
+
|
|
1453
|
+
`untracked_before` is the set of untracked paths captured immediately before
|
|
1454
|
+
this WU was dispatched (see `untracked_paths`). Those are the operator's —
|
|
1455
|
+
scratch notes, another harness's config, a local script — and are unstaged
|
|
1456
|
+
after `add -A` so they are not absorbed into the WU's commit (#150). Files
|
|
1457
|
+
the run itself created are still committed: the distinction is "did this
|
|
1458
|
+
dispatch create it", not "is it tracked".
|
|
1459
|
+
|
|
1460
|
+
Callers that omit the snapshot keep the legacy stage-everything behavior.
|
|
1461
|
+
"""
|
|
1348
1462
|
if git("rev-parse", "HEAD") != head_before:
|
|
1349
1463
|
git("reset", "--soft", head_before) # fold away any commits the agent made
|
|
1350
1464
|
if not git("status", "--porcelain"):
|
|
1351
1465
|
return None
|
|
1352
1466
|
git("add", "-A")
|
|
1467
|
+
if untracked_before:
|
|
1468
|
+
# Unstage the operator's pre-existing untracked files. Paths the agent
|
|
1469
|
+
# deleted during dispatch are skipped — `git reset -- <missing>` is fine,
|
|
1470
|
+
# but pathspec-matching nothing errors on some git versions.
|
|
1471
|
+
stale = sorted(p for p in untracked_before if Path(p).exists())
|
|
1472
|
+
if stale:
|
|
1473
|
+
git("reset", "-q", "--", *stale)
|
|
1474
|
+
# `add -A` may have had nothing else to stage: a WU whose only tree
|
|
1475
|
+
# change was the operator's WIP must not manufacture a commit from it.
|
|
1476
|
+
if not git("diff", "--cached", "--name-only"):
|
|
1477
|
+
return None
|
|
1353
1478
|
msg = f"feat: {wu.title}\n\nFeature: {wu.wu_id}"
|
|
1354
1479
|
res = subprocess.run(
|
|
1355
1480
|
["git", "commit", "-m", msg], capture_output=True, text=True,
|
|
@@ -2651,13 +2776,17 @@ _NO_FAILURES_SENTINEL = "### Failure-class breakdown\n\n(no non-passing attempts
|
|
|
2651
2776
|
def summarize_attempt_failure_classes(
|
|
2652
2777
|
feature_dir: Path,
|
|
2653
2778
|
gate_n: int | None = None,
|
|
2779
|
+
exclude_correlation_id: str | None = None,
|
|
2654
2780
|
) -> str:
|
|
2655
2781
|
"""Render a '### Failure-class breakdown' markdown table from events.jsonl.
|
|
2656
2782
|
|
|
2657
2783
|
Reads attempt_outcome events whose outcome != 'passed'. When gate_n is
|
|
2658
2784
|
provided, restricts to events whose correlation_id belongs to that gate
|
|
2659
|
-
(resolved via _gate_number_from_wu_id).
|
|
2660
|
-
|
|
2785
|
+
(resolved via _gate_number_from_wu_id). When exclude_correlation_id is
|
|
2786
|
+
provided, drops events with that exact correlation_id — used to keep a
|
|
2787
|
+
close WU's OWN non-passing attempts out of the breakdown it authors, so the
|
|
2788
|
+
close's stumble does not arm the guard against itself (issue #145). Returns
|
|
2789
|
+
_NO_FAILURES_SENTINEL when no non-passing attempts match the filter.
|
|
2661
2790
|
|
|
2662
2791
|
Pure function — reads events.jsonl; no writes, no side effects.
|
|
2663
2792
|
Malformed JSONL lines are skipped (legacy-event tolerance, AC5).
|
|
@@ -2680,8 +2809,10 @@ def summarize_attempt_failure_classes(
|
|
|
2680
2809
|
payload = evt.get("payload") or {}
|
|
2681
2810
|
if payload.get("outcome") == "passed":
|
|
2682
2811
|
continue
|
|
2812
|
+
cid = evt.get("correlation_id", "")
|
|
2813
|
+
if exclude_correlation_id is not None and cid == exclude_correlation_id:
|
|
2814
|
+
continue
|
|
2683
2815
|
if gate_n is not None:
|
|
2684
|
-
cid = evt.get("correlation_id", "")
|
|
2685
2816
|
if _gate_number_from_wu_id(cid) != gate_n:
|
|
2686
2817
|
continue
|
|
2687
2818
|
non_passing.append(payload)
|
|
@@ -2745,7 +2876,14 @@ def assert_failure_class_breakdown_when_failures_present(
|
|
|
2745
2876
|
return True, ""
|
|
2746
2877
|
|
|
2747
2878
|
gate_n = _gate_number_from_wu_id(wu.wu_id)
|
|
2748
|
-
|
|
2879
|
+
# Exclude the close WU's OWN non-passing attempts: the breakdown documents
|
|
2880
|
+
# SUBSTANTIVE-WU failures, not the close's own stumble. Without this, a
|
|
2881
|
+
# failed first close attempt retroactively requires a new subsection in the
|
|
2882
|
+
# RETROSPECTIVE the close itself authors — and the between-attempt
|
|
2883
|
+
# `reset --hard` wipes the partial each retry → spin → blocked_human on
|
|
2884
|
+
# otherwise-done features (issue #145).
|
|
2885
|
+
summary = summarize_attempt_failure_classes(
|
|
2886
|
+
feature_dir, gate_n, exclude_correlation_id=wu.wu_id)
|
|
2749
2887
|
|
|
2750
2888
|
if summary == _NO_FAILURES_SENTINEL:
|
|
2751
2889
|
return True, ""
|
|
@@ -2753,7 +2891,7 @@ def assert_failure_class_breakdown_when_failures_present(
|
|
|
2753
2891
|
if re.search(r"^#{3} Failure-class breakdown\b", retro.read_text(), re.MULTILINE):
|
|
2754
2892
|
return True, ""
|
|
2755
2893
|
|
|
2756
|
-
# Count non-passing attempts for the
|
|
2894
|
+
# Count non-passing attempts (excluding the close's own) for the message.
|
|
2757
2895
|
events_path = feature_dir / "events.jsonl"
|
|
2758
2896
|
count = 0
|
|
2759
2897
|
if events_path.exists():
|
|
@@ -2770,18 +2908,25 @@ def assert_failure_class_breakdown_when_failures_present(
|
|
|
2770
2908
|
payload = evt.get("payload") or {}
|
|
2771
2909
|
if payload.get("outcome") == "passed":
|
|
2772
2910
|
continue
|
|
2911
|
+
cid = evt.get("correlation_id", "")
|
|
2912
|
+
if cid == wu.wu_id:
|
|
2913
|
+
continue
|
|
2773
2914
|
if gate_n is not None:
|
|
2774
|
-
cid = evt.get("correlation_id", "")
|
|
2775
2915
|
if _gate_number_from_wu_id(cid) != gate_n:
|
|
2776
2916
|
continue
|
|
2777
2917
|
count += 1
|
|
2778
2918
|
|
|
2779
2919
|
gate_label = f"gate {gate_n}" if gate_n is not None else "all gates"
|
|
2920
|
+
# Actionable, self-contained retry note (flows into the next attempt's prompt
|
|
2921
|
+
# via failure_note, issue #145 fix #2): name the moved bar AND embed the exact
|
|
2922
|
+
# table to paste, so the agent doesn't rediscover the requirement.
|
|
2780
2923
|
return (
|
|
2781
2924
|
False,
|
|
2782
2925
|
f"assert_failure_class_breakdown_when_failures_present: {count} "
|
|
2783
|
-
f"
|
|
2784
|
-
f"
|
|
2926
|
+
f"substantive-WU attempt(s) in {gate_label} did not pass, so "
|
|
2927
|
+
f"RETROSPECTIVE.md MUST include a '### Failure-class breakdown' subsection "
|
|
2928
|
+
f"— it is absent. Add exactly this subsection to RETROSPECTIVE.md:\n\n"
|
|
2929
|
+
f"{summary}",
|
|
2785
2930
|
)
|
|
2786
2931
|
|
|
2787
2932
|
|
|
@@ -3394,6 +3539,11 @@ def run(
|
|
|
3394
3539
|
# Fall through to existing close-WU dispatch path
|
|
3395
3540
|
|
|
3396
3541
|
head_before = git("rev-parse", "HEAD")
|
|
3542
|
+
# Snapshot the operator's untracked files alongside head_before:
|
|
3543
|
+
# squash_commit must not absorb WIP that pre-dates this dispatch
|
|
3544
|
+
# (#150). Captured here, not inside squash_commit, because by
|
|
3545
|
+
# then the agent's own new files are indistinguishable from it.
|
|
3546
|
+
untracked_before = untracked_paths()
|
|
3397
3547
|
_is_rearm = detect_rearm_dispatch(wu)
|
|
3398
3548
|
if _is_rearm:
|
|
3399
3549
|
fold_cumulative_on_rearm(wu, backend)
|
|
@@ -3533,7 +3683,8 @@ def run(
|
|
|
3533
3683
|
backend.set_wu(wu, "status", DONE)
|
|
3534
3684
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
3535
3685
|
try:
|
|
3536
|
-
sha = squash_commit(wu, head_before
|
|
3686
|
+
sha = squash_commit(wu, head_before,
|
|
3687
|
+
untracked_before=untracked_before)
|
|
3537
3688
|
except SquashCommitError as exc:
|
|
3538
3689
|
# The squash commit was rejected (typically a
|
|
3539
3690
|
# pre-commit hook). Treat as a failed attempt rather
|
|
@@ -51,6 +51,7 @@ tests/test_autosync_plugin.py
|
|
|
51
51
|
tests/test_backend.py
|
|
52
52
|
tests/test_bookkeeping_commit_crash_run.py
|
|
53
53
|
tests/test_bookkeeping_commit_hook_crash.py
|
|
54
|
+
tests/test_close_wu_self_reference.py
|
|
54
55
|
tests/test_closing_deliverable_guard.py
|
|
55
56
|
tests/test_cost_tracking.py
|
|
56
57
|
tests/test_degraded_oracle.py
|
|
@@ -61,6 +62,7 @@ tests/test_driver_lock.py
|
|
|
61
62
|
tests/test_duration_tracking.py
|
|
62
63
|
tests/test_empty_files_escalation.py
|
|
63
64
|
tests/test_ensure_feature_branch.py
|
|
65
|
+
tests/test_ensure_feature_branch_base.py
|
|
64
66
|
tests/test_events_redaction.py
|
|
65
67
|
tests/test_extra_gates.py
|
|
66
68
|
tests/test_force_full_close.py
|
|
@@ -114,6 +116,7 @@ tests/test_planned_cost_lint.py
|
|
|
114
116
|
tests/test_prepare_feature.py
|
|
115
117
|
tests/test_prepare_scaffold_sync.py
|
|
116
118
|
tests/test_produces_field.py
|
|
119
|
+
tests/test_resolve_base.py
|
|
117
120
|
tests/test_result_block.py
|
|
118
121
|
tests/test_roadmap_add_skill.py
|
|
119
122
|
tests/test_roadmap_archive_skill.py
|
|
@@ -133,6 +136,7 @@ tests/test_template_closing_shapes.py
|
|
|
133
136
|
tests/test_terminal_flip_ownership.py
|
|
134
137
|
tests/test_terminal_flips.py
|
|
135
138
|
tests/test_untracked_feature_folder.py
|
|
139
|
+
tests/test_untracked_file_absorption.py
|
|
136
140
|
tests/test_upgrade_integration.py
|
|
137
141
|
tests/test_upgrade_merge_gate.py
|
|
138
142
|
tests/test_validate_event.py
|
|
@@ -17,7 +17,7 @@ import unittest
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
from tests._loop_loader import load_loop
|
|
20
|
-
from tests._workspace import integration_workspace
|
|
20
|
+
from tests._workspace import integration_workspace, with_deliverable
|
|
21
21
|
|
|
22
22
|
loop = load_loop()
|
|
23
23
|
|
|
@@ -154,6 +154,11 @@ class TestBackendLifecycleIntegration(unittest.TestCase):
|
|
|
154
154
|
|
|
155
155
|
def _patch(self, name: str, replacement):
|
|
156
156
|
self._patches.append((name, getattr(loop, name)))
|
|
157
|
+
# Dispatch stubs must write a deliverable or the presence gate
|
|
158
|
+
# (FEAT-2026-0022) rejects the WU as hollow. See #150 —
|
|
159
|
+
# `.specfuse/.loop.lock` used to stand in as the deliverable.
|
|
160
|
+
if name == "dispatch":
|
|
161
|
+
replacement = with_deliverable(replacement)
|
|
157
162
|
setattr(loop, name, replacement)
|
|
158
163
|
|
|
159
164
|
def test_on_feature_start_fires_before_dispatch_on_blocked_run(self):
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Issue #145 — a close WU's own failed attempt must not arm the
|
|
6
|
+
failure-class-breakdown guard against that same close WU.
|
|
7
|
+
|
|
8
|
+
`assert_failure_class_breakdown_when_failures_present` requires a
|
|
9
|
+
`### Failure-class breakdown` subsection in RETROSPECTIVE.md once non-passing
|
|
10
|
+
attempts exist in the gate. It counted ALL non-passing attempts — including the
|
|
11
|
+
close WU's OWN. Since the close WU is the one that authors the RETROSPECTIVE the
|
|
12
|
+
guard inspects, its malformed first attempt retroactively added a required
|
|
13
|
+
subsection to its own output, and the between-attempt `reset --hard` wiped the
|
|
14
|
+
partial each retry → spin → blocked_human on otherwise-done features.
|
|
15
|
+
|
|
16
|
+
Fix #3: exclude the close WU's own attempts (correlation_id == its wu_id) from
|
|
17
|
+
the failure set that arms the guard against it. Substantive-WU failures still
|
|
18
|
+
require the breakdown. Fix #2: when the guard does fire, its message names the
|
|
19
|
+
required subsection and embeds the exact expected table (it flows into the retry
|
|
20
|
+
prompt via failure_note), so the agent doesn't rediscover the moved bar.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import tempfile
|
|
27
|
+
import unittest
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from tests._loop_loader import load_loop
|
|
31
|
+
|
|
32
|
+
loop = load_loop()
|
|
33
|
+
|
|
34
|
+
CLOSE_ID = "FEAT-2026-0099/G1-CLOSE"
|
|
35
|
+
SUBSTANTIVE_ID = "FEAT-2026-0099/G1-T01"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _close_wu(wu_file: Path) -> "loop.WorkUnit":
|
|
39
|
+
return loop.WorkUnit(
|
|
40
|
+
wu_id=CLOSE_ID, file=wu_file, depends_on=[], type="close",
|
|
41
|
+
model="opus", effort="high", status="pending", attempts=1,
|
|
42
|
+
title="Close gate 1", body="body",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _attempt_event(correlation_id: str, outcome: str = "failed",
|
|
47
|
+
failure_class: str = "other", sig: str = "verdict_malformed") -> str:
|
|
48
|
+
return json.dumps({
|
|
49
|
+
"event_type": "attempt_outcome",
|
|
50
|
+
"correlation_id": correlation_id,
|
|
51
|
+
"timestamp": "2026-07-12T00:00:00Z",
|
|
52
|
+
"payload": {"outcome": outcome, "failure_class": failure_class,
|
|
53
|
+
"failure_signature": sig},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestCloseSelfReference(unittest.TestCase):
|
|
58
|
+
def _feature_dir(self, tmp: str, events: list[str], retro: str = "# Retro\n") -> Path:
|
|
59
|
+
d = Path(tmp)
|
|
60
|
+
(d / "events.jsonl").write_text("\n".join(events) + "\n")
|
|
61
|
+
(d / "RETROSPECTIVE.md").write_text(retro)
|
|
62
|
+
return d
|
|
63
|
+
|
|
64
|
+
def test_close_own_failure_does_not_arm_guard(self):
|
|
65
|
+
# Only the close WU's OWN attempts are non-passing (the observed run).
|
|
66
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
67
|
+
d = self._feature_dir(tmp, [_attempt_event(CLOSE_ID)])
|
|
68
|
+
wu = _close_wu(d / "WU.md")
|
|
69
|
+
ok, msg = loop.assert_failure_class_breakdown_when_failures_present(
|
|
70
|
+
wu, d, Path(tmp), "HEAD~1")
|
|
71
|
+
self.assertTrue(ok, f"close's own failure must not require the breakdown: {msg}")
|
|
72
|
+
|
|
73
|
+
def test_substantive_failure_still_requires_breakdown(self):
|
|
74
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
75
|
+
d = self._feature_dir(
|
|
76
|
+
tmp, [_attempt_event(SUBSTANTIVE_ID), _attempt_event(CLOSE_ID)])
|
|
77
|
+
wu = _close_wu(d / "WU.md")
|
|
78
|
+
ok, msg = loop.assert_failure_class_breakdown_when_failures_present(
|
|
79
|
+
wu, d, Path(tmp), "HEAD~1")
|
|
80
|
+
self.assertFalse(ok, "a substantive-WU failure still requires the breakdown")
|
|
81
|
+
# Fix #2 — message is actionable and embeds the exact table to include.
|
|
82
|
+
self.assertIn("### Failure-class breakdown", msg)
|
|
83
|
+
self.assertIn("| failure_class |", msg)
|
|
84
|
+
|
|
85
|
+
def test_breakdown_table_excludes_close_own_attempts(self):
|
|
86
|
+
# Two substantive failures + one close-own failure; the rendered table
|
|
87
|
+
# the guard offers must count only the two substantive ones.
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
d = self._feature_dir(
|
|
90
|
+
tmp,
|
|
91
|
+
[_attempt_event(SUBSTANTIVE_ID), _attempt_event(SUBSTANTIVE_ID),
|
|
92
|
+
_attempt_event(CLOSE_ID)])
|
|
93
|
+
table = loop.summarize_attempt_failure_classes(
|
|
94
|
+
d, gate_n=1, exclude_correlation_id=CLOSE_ID)
|
|
95
|
+
self.assertIn("**total** | **2**", table)
|
|
96
|
+
|
|
97
|
+
def test_summarize_all_self_returns_sentinel(self):
|
|
98
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
99
|
+
d = self._feature_dir(tmp, [_attempt_event(CLOSE_ID)])
|
|
100
|
+
table = loop.summarize_attempt_failure_classes(
|
|
101
|
+
d, gate_n=1, exclude_correlation_id=CLOSE_ID)
|
|
102
|
+
self.assertEqual(table, loop._NO_FAILURES_SENTINEL)
|
|
103
|
+
|
|
104
|
+
def test_breakdown_present_passes(self):
|
|
105
|
+
retro = "# Retro\n\n### Failure-class breakdown\n\n| x | 1 | y |\n"
|
|
106
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
107
|
+
d = self._feature_dir(tmp, [_attempt_event(SUBSTANTIVE_ID)], retro=retro)
|
|
108
|
+
wu = _close_wu(d / "WU.md")
|
|
109
|
+
ok, _ = loop.assert_failure_class_breakdown_when_failures_present(
|
|
110
|
+
wu, d, Path(tmp), "HEAD~1")
|
|
111
|
+
self.assertTrue(ok)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|
|
@@ -22,6 +22,7 @@ import unittest
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
24
|
from tests._loop_loader import load_loop
|
|
25
|
+
from tests._workspace import with_deliverable
|
|
25
26
|
from tests.test_driver_integration import (
|
|
26
27
|
integration_workspace,
|
|
27
28
|
write_minimal_feature,
|
|
@@ -125,6 +126,11 @@ class TestCostTrackingIntegration(unittest.TestCase):
|
|
|
125
126
|
|
|
126
127
|
def _patch(self, name: str, replacement):
|
|
127
128
|
self._patches.append((name, getattr(loop, name)))
|
|
129
|
+
# Dispatch stubs must write a deliverable or the presence gate
|
|
130
|
+
# (FEAT-2026-0022) rejects the WU as hollow. See #150 —
|
|
131
|
+
# `.specfuse/.loop.lock` used to stand in as the deliverable.
|
|
132
|
+
if name == "dispatch":
|
|
133
|
+
replacement = with_deliverable(replacement)
|
|
128
134
|
setattr(loop, name, replacement)
|
|
129
135
|
|
|
130
136
|
def test_enabled_writes_cost_to_wu_frontmatter_and_events(self):
|
|
@@ -26,7 +26,7 @@ import unittest
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
|
|
28
28
|
from tests._loop_loader import load_loop
|
|
29
|
-
from tests._workspace import integration_workspace
|
|
29
|
+
from tests._workspace import integration_workspace, with_deliverable
|
|
30
30
|
|
|
31
31
|
loop = load_loop()
|
|
32
32
|
|
|
@@ -90,6 +90,13 @@ gates:
|
|
|
90
90
|
f"---\nid: {wu_id}\ntype: {wu_type}\nmodel: claude-haiku-4-5-20251001\n"
|
|
91
91
|
f"status: {wu_status}\nattempts: 0\n---\n\n# {tnn}{body}"
|
|
92
92
|
)
|
|
93
|
+
# Mirror the real repo's .gitignore for driver-owned runtime files. Without
|
|
94
|
+
# this the fixture repo leaves `.specfuse/.loop.lock` untracked, and before
|
|
95
|
+
# #150 `squash_commit`'s `git add -A` committed it — where it silently
|
|
96
|
+
# satisfied the deliverable-presence guard for stub dispatches that write
|
|
97
|
+
# nothing. The lock file is not a deliverable; the real .gitignore has always
|
|
98
|
+
# excluded it, so the fixture models reality by doing the same.
|
|
99
|
+
(root / ".gitignore").write_text(".specfuse/.loop.lock\n")
|
|
93
100
|
# Stage and commit the scaffold so the driver starts from a clean tree.
|
|
94
101
|
subprocess.run(["git", "-C", str(root), "add", "."], check=True)
|
|
95
102
|
subprocess.run(["git", "-C", str(root), "commit", "-q", "-m",
|
|
@@ -147,6 +154,11 @@ class TestBug1StatusFlipSurvivesReset(unittest.TestCase):
|
|
|
147
154
|
|
|
148
155
|
def _patch(self, name: str, replacement):
|
|
149
156
|
self._patches.append((name, getattr(loop, name)))
|
|
157
|
+
# Dispatch stubs must write a deliverable or the presence gate
|
|
158
|
+
# (FEAT-2026-0022) rejects the WU as hollow. See #150 —
|
|
159
|
+
# `.specfuse/.loop.lock` used to stand in as the deliverable.
|
|
160
|
+
if name == "dispatch":
|
|
161
|
+
replacement = with_deliverable(replacement)
|
|
150
162
|
setattr(loop, name, replacement)
|
|
151
163
|
|
|
152
164
|
def test_passed_then_blocked_flips_persist(self):
|
|
@@ -253,6 +265,11 @@ class TestBug2FeatureBranchCheckout(unittest.TestCase):
|
|
|
253
265
|
|
|
254
266
|
def _patch(self, name: str, replacement):
|
|
255
267
|
self._patches.append((name, getattr(loop, name)))
|
|
268
|
+
# Dispatch stubs must write a deliverable or the presence gate
|
|
269
|
+
# (FEAT-2026-0022) rejects the WU as hollow. See #150 —
|
|
270
|
+
# `.specfuse/.loop.lock` used to stand in as the deliverable.
|
|
271
|
+
if name == "dispatch":
|
|
272
|
+
replacement = with_deliverable(replacement)
|
|
256
273
|
setattr(loop, name, replacement)
|
|
257
274
|
|
|
258
275
|
def test_driver_switches_to_declared_branch(self):
|