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.
Files changed (150) hide show
  1. {specfuse_loop-0.3.12/specfuse_loop.egg-info → specfuse_loop-0.3.14}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.14/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/gh_backend.py +11 -6
  5. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/lint_plan.py +9 -0
  6. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/loop.py +182 -31
  7. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14/specfuse_loop.egg-info}/PKG-INFO +1 -1
  8. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/SOURCES.txt +4 -0
  9. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_backend.py +6 -1
  10. specfuse_loop-0.3.14/tests/test_close_wu_self_reference.py +115 -0
  11. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_cost_tracking.py +6 -0
  12. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_driver_integration.py +18 -1
  13. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_duration_tracking.py +6 -0
  14. specfuse_loop-0.3.14/tests/test_ensure_feature_branch_base.py +159 -0
  15. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gh_backend.py +57 -14
  16. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_plan_errors.py +41 -0
  17. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_files_changed_guard.py +8 -1
  18. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_orchestration.py +6 -0
  19. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_zero_token_guard.py +6 -1
  20. specfuse_loop-0.3.14/tests/test_resolve_base.py +180 -0
  21. specfuse_loop-0.3.14/tests/test_untracked_file_absorption.py +218 -0
  22. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_wu_execution_metadata.py +6 -0
  23. specfuse_loop-0.3.12/specfuse/loop/data/VERSION +0 -1
  24. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/LICENSE +0 -0
  25. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/NOTICE +0 -0
  26. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/README.md +0 -0
  27. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/setup.cfg +0 -0
  28. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/__init__.py +0 -0
  29. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/_miniyaml.py +0 -0
  30. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/adopt_feature.py +0 -0
  31. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  32. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  33. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  34. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/getting-started.md +0 -0
  35. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/methodology.md +0 -0
  36. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/docs/skills.md +0 -0
  37. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/gitignore.snippet +0 -0
  38. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/roadmap.template.md +0 -0
  39. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  40. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/never-touch.md +0 -0
  41. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/result-contract.md +0 -0
  42. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  43. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  44. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/event.schema.json +0 -0
  45. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  46. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  47. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  48. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  49. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/GATE.template.md +0 -0
  50. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  51. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/templates/WU.template.md +0 -0
  52. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/data/verification.yml.example +0 -0
  53. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/gate_eval.py +0 -0
  54. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/gh_features.py +0 -0
  55. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/scaffold.py +0 -0
  56. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse/loop/validate_event.py +0 -0
  57. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  58. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/entry_points.txt +0 -0
  59. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/requires.txt +0 -0
  60. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/specfuse_loop.egg-info/top_level.txt +0 -0
  61. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_adopt_feature.py +0 -0
  62. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  63. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_attempt_outcome_emission.py +0 -0
  64. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync.py +0 -0
  65. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync_consent.py +0 -0
  66. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync_firstrun.py +0 -0
  67. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_autosync_plugin.py +0 -0
  68. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  69. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  70. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_closing_deliverable_guard.py +0 -0
  71. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_degraded_oracle.py +0 -0
  72. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_deliverable_presence_gate.py +0 -0
  73. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_doctor.py +0 -0
  74. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_driver_lock.py +0 -0
  75. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_empty_files_escalation.py +0 -0
  76. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_ensure_feature_branch.py +0 -0
  77. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_events_redaction.py +0 -0
  78. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_extra_gates.py +0 -0
  79. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_force_full_close.py +0 -0
  80. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval.py +0 -0
  81. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval_calibration.py +0 -0
  82. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  83. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gate_eval_terminal_wiring.py +0 -0
  84. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_gh_features.py +0 -0
  85. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_git_env_isolation.py +0 -0
  86. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_hashed_denylist.py +0 -0
  87. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_hashed_denylist_ci.py +0 -0
  88. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_init_integration.py +0 -0
  89. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_leak_findings_redaction.py +0 -0
  90. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_leak_scan.py +0 -0
  91. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_leak_scan_content.py +0 -0
  92. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_learnings_query.py +0 -0
  93. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  94. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lifecycle_integration.py +0 -0
  95. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_bare_produces_path.py +0 -0
  96. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_close_intermediate.py +0 -0
  97. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_close_wu.py +0 -0
  98. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id.py +0 -0
  99. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  100. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_oracle_env.py +0 -0
  101. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_plan_next_draft.py +0 -0
  102. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_plan_relative_import.py +0 -0
  103. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_produces_driver_helper.py +0 -0
  104. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_sections.py +0 -0
  105. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  106. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_auto_archive.py +0 -0
  107. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_caveman_preamble.py +0 -0
  108. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_close_intermediate.py +0 -0
  109. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_defaults_by_type.py +0 -0
  110. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_effort.py +0 -0
  111. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_failure_note_cap.py +0 -0
  112. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_gate_budget.py +0 -0
  113. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_model_alias.py +0 -0
  114. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_post_pass_invariant.py +0 -0
  115. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_reset_preserving_events.py +0 -0
  116. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_smoke_runner.py +0 -0
  117. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_loop_unsandboxed.py +0 -0
  118. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_migrate_legacy.py +0 -0
  119. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_miniyaml_equivalence.py +0 -0
  120. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_miniyaml_negative.py +0 -0
  121. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_planned_cost_lint.py +0 -0
  122. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_prepare_feature.py +0 -0
  123. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_prepare_scaffold_sync.py +0 -0
  124. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_produces_field.py +0 -0
  125. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_result_block.py +0 -0
  126. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_roadmap_add_skill.py +0 -0
  127. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_roadmap_archive_skill.py +0 -0
  128. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_roadmap_row_parser.py +0 -0
  129. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_data_in_sync.py +0 -0
  130. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_doc_hygiene.py +0 -0
  131. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_docs.py +0 -0
  132. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_init.py +0 -0
  133. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_manifest.py +0 -0
  134. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_resources.py +0 -0
  135. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_seed_sanity.py +0 -0
  136. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_upgrade.py +0 -0
  137. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_scaffold_wiring.py +0 -0
  138. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_skills_vendored_in_sync.py +0 -0
  139. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_squash_commit_hook_crash.py +0 -0
  140. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_template_closing_shapes.py +0 -0
  141. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_terminal_flip_ownership.py +0 -0
  142. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_terminal_flips.py +0 -0
  143. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_untracked_feature_folder.py +0 -0
  144. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_upgrade_integration.py +0 -0
  145. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_upgrade_merge_gate.py +0 -0
  146. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_validate_event.py +0 -0
  147. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_verdict_coupling.py +0 -0
  148. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_verify_empty_gate_set.py +0 -0
  149. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_version_consistency.py +0 -0
  150. {specfuse_loop-0.3.12 → specfuse_loop-0.3.14}/tests/test_version_skew.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.12
3
+ Version: 0.3.14
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specfuse-loop"
7
- version = "0.3.12"
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) -> None:
17
- """Shell out to gh with the given argument list. Not called in tests."""
18
- subprocess.run(args, check=True, capture_output=True, text=True)
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 = subprocess.run(
63
+ check = self._runner(
59
64
  ["gh", "pr", "view", branch, "--repo", self.repo, "--json", "number"],
60
- capture_output=True, text=True,
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", "main",
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.12"
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`) and carry harmlessly, so counting them would spuriously
727
- flag a leftover events.jsonl as an "unexpected" change. The dirty-tree
728
- failure in #48 is tracked local modifications ("your local changes would
729
- be overwritten by checkout"), which is exactly what this set captures.
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 current base instead of
1068
- # silently reusing it. `merge-base --is-ancestor B HEAD` exits 0 iff B
1069
- # is an ancestor of HEAD (i.e. HEAD already contains B — safe to reuse).
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, "HEAD"],
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}' already exists and has diverged from your "
1077
- f"current branch '{current}' (it carries commits HEAD does not). "
1078
- f"Refusing to silently check it out that could build on a stale "
1079
- f"base or lose work. Pick one, then re-run:\n"
1080
- f" - resume that branch's work: git checkout {branch}\n"
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-HEAD carries the working tree onto the new branch. Only
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 '{current}'.")
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(wu: WorkUnit, head_before: str) -> str | None:
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). Returns _NO_FAILURES_SENTINEL when
2660
- no non-passing attempts match the filter.
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
- summary = summarize_attempt_failure_classes(feature_dir, gate_n)
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 error message.
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"non-passing attempt(s) in {gate_label} but '### Failure-class breakdown' "
2784
- f"subsection absent from RETROSPECTIVE.md",
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.12
3
+ Version: 0.3.14
4
4
  Summary: Local-first executor for the Specfuse Plan + Work Unit gate-cycle methodology.
5
5
  Author: Specfuse contributors
6
6
  License: Apache-2.0
@@ -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):