specfuse-loop 0.3.1__tar.gz → 0.3.3__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 (132) hide show
  1. {specfuse_loop-0.3.1/specfuse_loop.egg-info → specfuse_loop-0.3.3}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.3/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/getting-started.md +10 -1
  5. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/GATE.template.md +0 -6
  6. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/PLAN.template.md +0 -6
  7. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/WU.template.md +3 -6
  8. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/loop.py +91 -14
  9. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3/specfuse_loop.egg-info}/PKG-INFO +1 -1
  10. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/SOURCES.txt +3 -1
  11. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_arm_gate_edits_uncommitted.py +4 -4
  12. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_orchestration.py +2 -2
  13. specfuse_loop-0.3.3/tests/test_prepare_feature.py +121 -0
  14. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_untracked_feature_folder.py +3 -3
  15. specfuse_loop-0.3.3/tests/test_wu_execution_metadata.py +115 -0
  16. specfuse_loop-0.3.1/specfuse/loop/data/VERSION +0 -1
  17. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/LICENSE +0 -0
  18. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/NOTICE +0 -0
  19. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/README.md +0 -0
  20. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/setup.cfg +0 -0
  21. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/__init__.py +0 -0
  22. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/_miniyaml.py +0 -0
  23. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/adopt_feature.py +0 -0
  24. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  25. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  26. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  27. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/methodology.md +0 -0
  28. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/skills.md +0 -0
  29. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/gitignore.snippet +0 -0
  30. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/roadmap.template.md +0 -0
  31. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  32. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/never-touch.md +0 -0
  33. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/result-contract.md +0 -0
  34. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  35. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/data/verification.yml.example +0 -0
  36. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/gate_eval.py +0 -0
  37. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/gh_backend.py +0 -0
  38. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/gh_features.py +0 -0
  39. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/lint_plan.py +0 -0
  40. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/scaffold.py +0 -0
  41. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse/loop/validate_event.py +0 -0
  42. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  43. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/entry_points.txt +0 -0
  44. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/requires.txt +0 -0
  45. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/top_level.txt +0 -0
  46. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_adopt_feature.py +0 -0
  47. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_attempt_outcome_emission.py +0 -0
  48. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_autosync.py +0 -0
  49. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_autosync_consent.py +0 -0
  50. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_autosync_firstrun.py +0 -0
  51. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_autosync_plugin.py +0 -0
  52. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_backend.py +0 -0
  53. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  54. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  55. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_closing_deliverable_guard.py +0 -0
  56. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_cost_tracking.py +0 -0
  57. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_deliverable_presence_gate.py +0 -0
  58. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_doctor.py +0 -0
  59. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_driver_integration.py +0 -0
  60. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_driver_lock.py +0 -0
  61. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_duration_tracking.py +0 -0
  62. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_empty_files_escalation.py +0 -0
  63. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_ensure_feature_branch.py +0 -0
  64. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_extra_gates.py +0 -0
  65. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_force_full_close.py +0 -0
  66. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_gate_eval.py +0 -0
  67. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_gate_eval_calibration.py +0 -0
  68. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  69. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_gate_eval_terminal_wiring.py +0 -0
  70. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_gh_backend.py +0 -0
  71. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_gh_features.py +0 -0
  72. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_git_env_isolation.py +0 -0
  73. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_hashed_denylist.py +0 -0
  74. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_hashed_denylist_ci.py +0 -0
  75. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_init_integration.py +0 -0
  76. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_leak_findings_redaction.py +0 -0
  77. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_leak_scan.py +0 -0
  78. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_leak_scan_content.py +0 -0
  79. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  80. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lifecycle_integration.py +0 -0
  81. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_bare_produces_path.py +0 -0
  82. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_close_intermediate.py +0 -0
  83. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_close_wu.py +0 -0
  84. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id.py +0 -0
  85. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  86. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_oracle_env.py +0 -0
  87. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_plan_errors.py +0 -0
  88. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_plan_next_draft.py +0 -0
  89. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_produces_driver_helper.py +0 -0
  90. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_sections.py +0 -0
  91. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  92. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_auto_archive.py +0 -0
  93. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_caveman_preamble.py +0 -0
  94. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_close_intermediate.py +0 -0
  95. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_defaults_by_type.py +0 -0
  96. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_effort.py +0 -0
  97. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_failure_note_cap.py +0 -0
  98. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_files_changed_guard.py +0 -0
  99. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_gate_budget.py +0 -0
  100. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_model_alias.py +0 -0
  101. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_post_pass_invariant.py +0 -0
  102. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_reset_preserving_events.py +0 -0
  103. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_smoke_runner.py +0 -0
  104. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_unsandboxed.py +0 -0
  105. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_loop_zero_token_guard.py +0 -0
  106. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_migrate_legacy.py +0 -0
  107. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_miniyaml_equivalence.py +0 -0
  108. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_miniyaml_negative.py +0 -0
  109. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_planned_cost_lint.py +0 -0
  110. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_produces_field.py +0 -0
  111. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_result_block.py +0 -0
  112. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_roadmap_add_skill.py +0 -0
  113. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_roadmap_archive_skill.py +0 -0
  114. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_roadmap_row_parser.py +0 -0
  115. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_data_in_sync.py +0 -0
  116. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_docs.py +0 -0
  117. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_init.py +0 -0
  118. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_manifest.py +0 -0
  119. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_resources.py +0 -0
  120. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_seed_sanity.py +0 -0
  121. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_upgrade.py +0 -0
  122. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_scaffold_wiring.py +0 -0
  123. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_squash_commit_hook_crash.py +0 -0
  124. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_template_closing_shapes.py +0 -0
  125. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_terminal_flip_ownership.py +0 -0
  126. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_terminal_flips.py +0 -0
  127. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_upgrade_integration.py +0 -0
  128. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_validate_event.py +0 -0
  129. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_verdict_coupling.py +0 -0
  130. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_verify_empty_gate_set.py +0 -0
  131. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/tests/test_version_consistency.py +0 -0
  132. {specfuse_loop-0.3.1 → specfuse_loop-0.3.3}/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.1
3
+ Version: 0.3.3
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.1"
7
+ version = "0.3.3"
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.3
@@ -109,7 +109,16 @@ A feature folder holds:
109
109
  | `GATE-NN.md` | one gate's status and definition of done | you / the planner |
110
110
  | `WU-*.md` | a single work unit: frontmatter + the prompt a fresh session receives | you / `draft-feature` / `plan-next` |
111
111
 
112
- Then create the branch named in `PLAN.md`'s frontmatter (`branch:`).
112
+ `draft-feature` writes the folder but doesn't commit it or switch branches, so
113
+ the loop will refuse to start until the folder is on its branch and committed.
114
+ Let the driver do both for you:
115
+
116
+ ```bash
117
+ specfuse-loop --prepare # create the PLAN.md `branch:`, commit the folder, then run
118
+ ```
119
+
120
+ (Or by hand: `git checkout -b <branch>` from `PLAN.md`'s frontmatter, then
121
+ `git add` + `git commit` the feature folder.)
113
122
 
114
123
  ## 4. Validate before running
115
124
 
@@ -14,12 +14,6 @@ status: open # open | awaiting_review | passed
14
14
  # set it in the successor gate to exercise the brake for the first time.
15
15
  ---
16
16
 
17
- <!--
18
- Copyright 2026 Specfuse Contributors
19
- Licensed under the Apache License, Version 2.0. See LICENSE.
20
- -->
21
-
22
-
23
17
  # Gate 1 — <name of the milestone this gate proves>
24
18
 
25
19
  ## Definition of done
@@ -11,12 +11,6 @@ status: active # active | done | abandoned
11
11
  # planned_cost_usd: 0.00 # OPTIONAL — sum of WU planned costs; lint warns when missing or when delta from WU sum > 10%
12
12
  ---
13
13
 
14
- <!--
15
- Copyright 2026 Specfuse Contributors
16
- Licensed under the Apache License, Version 2.0. See LICENSE.
17
- -->
18
-
19
-
20
14
  # Plan: <short feature title>
21
15
 
22
16
  <One or two paragraphs of human-facing intent. The work units carry the executable
@@ -10,12 +10,6 @@ generated_surfaces: [] # OPTIONAL — paths to generated files this unit's a
10
10
  # oracle_env: macos_local # OPTIONAL — environment where this WU's verifying oracle runs; see frontmatter notes
11
11
  ---
12
12
 
13
- <!--
14
- Copyright 2026 Specfuse Contributors
15
- Licensed under the Apache License, Version 2.0. See LICENSE.
16
- -->
17
-
18
-
19
13
  <!--
20
14
  Frontmatter notes (single-repo):
21
15
 
@@ -48,6 +42,9 @@ DRIVER-OWNED FIELDS — the driver writes these at outcome time; authors leave t
48
42
  <!-- driver-owned: attempts, cost_usd, input_tokens, output_tokens, duration_seconds,
49
43
  cumulative_cost_usd, cumulative_duration_seconds, cumulative_input_tokens,
50
44
  cumulative_output_tokens, re_arm_count, re_arm_history -->
45
+ <!-- driver-stamped at dispatch (resolved execution metadata, visible in this .md):
46
+ model, effort (override or type default), gate_set (the verification.yml set
47
+ that is this WU's exit oracle), driver_version, started_at (UTC ISO). -->
51
48
  <!-- Full field semantics in docs/methodology.md §2 and events.jsonl outcome payloads. -->
52
49
 
53
50
  Dependencies live in PLAN.md's `gates[].work_units[].depends_on` graph, not
@@ -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.1"
65
+ DRIVER_VERSION = "0.3.3"
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
@@ -709,7 +709,39 @@ def untracked_feature_files(feature_dir: "Path") -> list[str]:
709
709
  return [ln.strip() for ln in out.splitlines() if ln.strip()]
710
710
 
711
711
 
712
- def require_feature_folder_committed(feature_dir: "Path") -> None:
712
+ def _current_branch() -> str:
713
+ """The checked-out branch name ('' when detached)."""
714
+ return git("branch", "--show-current")
715
+
716
+
717
+ def _branch_prep_hint(feature_dir: "Path", feat_fm: dict, feature_id: str) -> str:
718
+ """Actionable next-step block for the two pre-flight guards.
719
+
720
+ `draft-feature` writes the feature folder but neither creates the feature's
721
+ branch nor commits — so the loop refuses to start. The fix is two steps when
722
+ you're not yet on the feature's branch (create it, then commit), and the
723
+ `--prepare` flag does both for you. Build a hint that names the real branch
724
+ and only includes the checkout line when it's actually needed.
725
+ """
726
+ branch = feat_fm.get("branch") or f"feat/{feature_id}"
727
+ on_branch = _current_branch() == branch
728
+ lines = [
729
+ "",
730
+ "Easiest — let the loop create the branch and commit for you:",
731
+ " specfuse-loop --prepare",
732
+ "",
733
+ "Or do it manually:",
734
+ ]
735
+ if not on_branch:
736
+ lines.append(f" git checkout -b {branch}")
737
+ lines.append(f" git add {feature_dir}")
738
+ lines.append(f" git commit -m 'chore: scaffold feature {feature_id}'")
739
+ return "\n".join(lines)
740
+
741
+
742
+ def require_feature_folder_committed(
743
+ feature_dir: "Path", feat_fm: dict, feature_id: str,
744
+ ) -> None:
713
745
  """Hard-stop if the dispatched feature folder has untracked files (#71).
714
746
 
715
747
  The per-attempt `git reset --hard head_before` rolls tracked files back to
@@ -726,12 +758,11 @@ def require_feature_folder_committed(feature_dir: "Path") -> None:
726
758
  listed = "\n ".join(sorted(untracked))
727
759
  sys.exit(
728
760
  f"loop.py: feature folder '{feature_dir}' has untracked files — "
729
- f"commit them before running the loop, or the per-attempt "
730
- f"`git reset --hard` will delete the folder mid-run (and the driver "
731
- f"will crash reading a WU file that no longer exists). Untracked:\n"
732
- f" {listed}\n"
733
- f"Fix: `git add {feature_dir} && git commit -m "
734
- f"'chore: commit feature folder'`, then re-run."
761
+ f"create the feature branch and commit them before running, or the "
762
+ f"per-attempt `git reset --hard` will delete the folder mid-run (and "
763
+ f"the driver will crash reading a WU file that no longer exists). "
764
+ f"Untracked:\n {listed}\n"
765
+ + _branch_prep_hint(feature_dir, feat_fm, feature_id)
735
766
  )
736
767
 
737
768
 
@@ -786,7 +817,9 @@ def feature_folder_tracked_modifications(feature_dir: "Path") -> list[str]:
786
817
  return mods
787
818
 
788
819
 
789
- def require_feature_folder_unmodified(feature_dir: "Path") -> None:
820
+ def require_feature_folder_unmodified(
821
+ feature_dir: "Path", feat_fm: dict, feature_id: str,
822
+ ) -> None:
790
823
  """Hard-stop on uncommitted arm-gate / WU-revision edits before dispatch (#74).
791
824
 
792
825
  arm-gate (and any manual WU-body revision at a gate boundary) writes
@@ -808,11 +841,30 @@ def require_feature_folder_unmodified(feature_dir: "Path") -> None:
808
841
  f"`git reset --hard` will discard them (your armed WU statuses and "
809
842
  f"any acceptance-criteria revisions would silently revert). "
810
843
  f"Modified:\n {listed}\n"
811
- f"Fix: `git add {feature_dir} && git commit -m "
812
- f"'chore(arm): commit gate edits'`, then re-run."
844
+ + _branch_prep_hint(feature_dir, feat_fm, feature_id)
813
845
  )
814
846
 
815
847
 
848
+ def prepare_feature(feat_fm: dict, feature_dir: "Path", feature_id: str) -> None:
849
+ """`--prepare`: put the feature folder on its declared branch and commit it.
850
+
851
+ `draft-feature` leaves the folder uncommitted on whatever branch you were on
852
+ (usually the default). This creates/checks out the feature's branch (from
853
+ PLAN.md `branch:`, carrying the untracked folder along) and commits the
854
+ folder, so the pre-flight guards pass and the run can start. Idempotent: a
855
+ no-op commit when there is nothing to commit.
856
+ """
857
+ branch = feat_fm.get("branch")
858
+ ensure_feature_branch(feat_fm, feature_dir)
859
+ git("add", "--", str(feature_dir))
860
+ staged = git("status", "--porcelain", "--", str(feature_dir))
861
+ if staged:
862
+ git("commit", "-m", f"chore: scaffold feature {feature_id}")
863
+ print(f"Prepared: committed feature folder on branch '{branch}'.")
864
+ else:
865
+ print(f"Prepare: feature folder already committed on branch '{branch}'.")
866
+
867
+
816
868
  def _checked_checkout(checkout_args: list[str], action: str) -> str:
817
869
  """Run a `git checkout ...` guarded: on non-zero exit raise FeatureBranchError
818
870
  carrying git's stderr, instead of a bare CalledProcessError that hides it.
@@ -2921,6 +2973,7 @@ def run(
2921
2973
  feature_arg: str | None,
2922
2974
  dry_run: bool,
2923
2975
  force_full_close: str | None = None,
2976
+ prepare: bool = False,
2924
2977
  ) -> int:
2925
2978
  # Fail-fast on a malformed verification.yml BEFORE we touch any WU state.
2926
2979
  # The per-gate `verify()` call lazy-loads the same file; if it's malformed,
@@ -2974,6 +3027,10 @@ def run(
2974
3027
  )
2975
3028
  return 1
2976
3029
  require_git_ready()
3030
+ # --prepare: create the feature branch + commit the folder up front so
3031
+ # the guards below pass (the do-it-for-me path draft-feature can't take).
3032
+ if prepare:
3033
+ prepare_feature(feat_fm, feature_dir, feature_id)
2977
3034
  # Pre-flight guards — both run BEFORE ensure_feature_branch so the
2978
3035
  # refusal happens before any branch mutation, and both protect against
2979
3036
  # the per-attempt `git reset --hard` destroying uncommitted state:
@@ -2981,8 +3038,8 @@ def run(
2981
3038
  # deleted by the reset (and crash the next frontmatter write).
2982
3039
  # #74 — uncommitted arm-gate / WU-revision edits (armed statuses, AC
2983
3040
  # revisions) would be silently discarded by the reset.
2984
- require_feature_folder_committed(feature_dir)
2985
- require_feature_folder_unmodified(feature_dir)
3041
+ require_feature_folder_committed(feature_dir, feat_fm, feature_id)
3042
+ require_feature_folder_unmodified(feature_dir, feat_fm, feature_id)
2986
3043
  ensure_feature_branch(feat_fm, feature_dir)
2987
3044
 
2988
3045
  try:
@@ -3156,6 +3213,20 @@ def run(
3156
3213
  if _is_rearm:
3157
3214
  fold_cumulative_on_rearm(wu, backend)
3158
3215
  backend.set_wu(wu, "status", "in_progress")
3216
+ # Stamp the resolved execution metadata into the WU frontmatter so
3217
+ # it is visible when you read the .md (not only on the console /
3218
+ # in events.jsonl): the model + effort it runs with (override or
3219
+ # type default), which verification gate set is its exit oracle,
3220
+ # the driver version that executed it, and when it was dispatched.
3221
+ # Written after head_before so they ride the WU's squash commit;
3222
+ # idempotent on retries/re-arms (same values overwrite cleanly,
3223
+ # started_at refreshes to the latest dispatch).
3224
+ backend.set_wu(wu, "model", wu.model)
3225
+ backend.set_wu(wu, "effort", wu.effort)
3226
+ backend.set_wu(wu, "gate_set", GATES_FOR_TYPE.get(wu.type, "code"))
3227
+ backend.set_wu(wu, "driver_version", DRIVER_VERSION)
3228
+ backend.set_wu(wu, "started_at",
3229
+ dt.datetime.now(dt.timezone.utc).isoformat())
3159
3230
  # Events and per-attempt notes are buffered in memory during the
3160
3231
  # WU's lifecycle and flushed at outcome time. This prevents the
3161
3232
  # `git reset --hard` between failed attempts from silently
@@ -3938,11 +4009,17 @@ def main() -> int:
3938
4009
  "path for the named feature. Must match the feature being processed.")
3939
4010
  ap.add_argument("--no-autosync", action="store_true",
3940
4011
  help="Skip auto-sync entirely (no scaffold create or overlay).")
4012
+ ap.add_argument("--prepare", action="store_true",
4013
+ help="Create the active feature's branch (from PLAN.md "
4014
+ "'branch:') and commit its folder, then run — the do-it-for-me "
4015
+ "path after /draft-feature, which leaves the folder "
4016
+ "uncommitted on the current branch.")
3941
4017
  args = ap.parse_args()
3942
4018
  if not FEATURES_DIR.exists():
3943
4019
  sys.exit(f"No {FEATURES_DIR}. Run from your repo root.")
3944
4020
  auto_sync(dry_run=args.dry_run, no_autosync=args.no_autosync)
3945
- return run(args.feature, args.dry_run, force_full_close=args.force_full_close)
4021
+ return run(args.feature, args.dry_run, force_full_close=args.force_full_close,
4022
+ prepare=args.prepare)
3946
4023
 
3947
4024
 
3948
4025
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -101,6 +101,7 @@ tests/test_migrate_legacy.py
101
101
  tests/test_miniyaml_equivalence.py
102
102
  tests/test_miniyaml_negative.py
103
103
  tests/test_planned_cost_lint.py
104
+ tests/test_prepare_feature.py
104
105
  tests/test_produces_field.py
105
106
  tests/test_result_block.py
106
107
  tests/test_roadmap_add_skill.py
@@ -124,4 +125,5 @@ tests/test_validate_event.py
124
125
  tests/test_verdict_coupling.py
125
126
  tests/test_verify_empty_gate_set.py
126
127
  tests/test_version_consistency.py
127
- tests/test_version_skew.py
128
+ tests/test_version_skew.py
129
+ tests/test_wu_execution_metadata.py
@@ -81,7 +81,7 @@ class TestFeatureFolderTrackedModifications(unittest.TestCase):
81
81
  mods = loop.feature_folder_tracked_modifications(rel)
82
82
  self.assertTrue(any("WU-05.md" in m for m in mods))
83
83
  with self.assertRaises(SystemExit):
84
- loop.require_feature_folder_unmodified(rel)
84
+ loop.require_feature_folder_unmodified(rel, {"branch": "feat/t"}, "FEAT-2026-9999")
85
85
 
86
86
  def test_clean_folder_passes(self):
87
87
  with tempfile.TemporaryDirectory() as tmp:
@@ -91,7 +91,7 @@ class TestFeatureFolderTrackedModifications(unittest.TestCase):
91
91
  os.chdir(root)
92
92
  rel = fdir.relative_to(root)
93
93
  self.assertEqual(loop.feature_folder_tracked_modifications(rel), [])
94
- loop.require_feature_folder_unmodified(rel) # must not raise
94
+ loop.require_feature_folder_unmodified(rel, {"branch": "feat/t"}, "FEAT-2026-9999") # must not raise
95
95
 
96
96
  def test_plan_md_flip_does_not_block(self):
97
97
  """pick-feature's PLAN.md status flip is expected, not an arm edit."""
@@ -106,7 +106,7 @@ class TestFeatureFolderTrackedModifications(unittest.TestCase):
106
106
  os.chdir(root)
107
107
  rel = fdir.relative_to(root)
108
108
  self.assertEqual(loop.feature_folder_tracked_modifications(rel), [])
109
- loop.require_feature_folder_unmodified(rel) # must not raise
109
+ loop.require_feature_folder_unmodified(rel, {"branch": "feat/t"}, "FEAT-2026-9999") # must not raise
110
110
 
111
111
  def test_events_jsonl_change_does_not_block(self):
112
112
  """events.jsonl is driver-managed (preserved across reset), not an arm edit."""
@@ -118,7 +118,7 @@ class TestFeatureFolderTrackedModifications(unittest.TestCase):
118
118
  os.chdir(root)
119
119
  rel = fdir.relative_to(root)
120
120
  self.assertEqual(loop.feature_folder_tracked_modifications(rel), [])
121
- loop.require_feature_folder_unmodified(rel) # must not raise
121
+ loop.require_feature_folder_unmodified(rel, {"branch": "feat/t"}, "FEAT-2026-9999") # must not raise
122
122
 
123
123
 
124
124
  class TestRunRefusesUncommittedArmEdits(unittest.TestCase):
@@ -706,7 +706,7 @@ class TestMainArgparse(unittest.TestCase):
706
706
  with patch.object(sys, "argv", ["loop", "--feature", "my-feature"]):
707
707
  result = loop.main()
708
708
  self.assertEqual(result, 0)
709
- mock_run.assert_called_once_with("my-feature", False, force_full_close=None)
709
+ mock_run.assert_called_once_with("my-feature", False, force_full_close=None, prepare=False)
710
710
 
711
711
  def test_dry_run_flag_forwarded_to_run(self):
712
712
  """--dry-run is forwarded as dry_run=True to run()."""
@@ -718,7 +718,7 @@ class TestMainArgparse(unittest.TestCase):
718
718
  with patch.object(loop, "run", return_value=0) as mock_run:
719
719
  with patch.object(sys, "argv", ["loop", "--dry-run"]):
720
720
  loop.main()
721
- mock_run.assert_called_once_with(None, True, force_full_close=None)
721
+ mock_run.assert_called_once_with(None, True, force_full_close=None, prepare=False)
722
722
 
723
723
  def test_multi_active_error_propagates_from_run(self):
724
724
  """main() with no args propagates the find_feature multi-active SystemExit."""
@@ -0,0 +1,121 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """--prepare and the branch-aware guard hints.
6
+
7
+ `draft-feature` writes the feature folder but neither creates the feature's
8
+ branch nor commits, so the loop refuses to start (#71/#74). The refusal must
9
+ tell you to create the branch first (when you're on the default branch), and
10
+ `--prepare` must do branch-create + commit for you.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import os
17
+ import subprocess
18
+ import tempfile
19
+ import unittest
20
+ from contextlib import redirect_stderr
21
+ from pathlib import Path
22
+
23
+ from tests._loop_loader import load_loop
24
+
25
+ loop = load_loop()
26
+
27
+ _FEAT_ID = "FEAT-2026-9999"
28
+ _BRANCH = "feat/FEAT-2026-9999-thing"
29
+
30
+
31
+ def _init_git(root: Path) -> None:
32
+ subprocess.run(["git", "init", "-q", "-b", "main", str(root)], check=True)
33
+ subprocess.run(["git", "-C", str(root), "config", "user.email", "t@example.com"],
34
+ check=True)
35
+ subprocess.run(["git", "-C", str(root), "config", "user.name", "Test"], check=True)
36
+ subprocess.run(["git", "-C", str(root), "config", "commit.gpgSign", "false"],
37
+ check=True)
38
+ (root / "README.md").write_text("# fixture\n")
39
+ subprocess.run(["git", "-C", str(root), "add", "."], check=True)
40
+ subprocess.run(["git", "-C", str(root), "commit", "-q", "-m", "init"], check=True)
41
+
42
+
43
+ def _write_uncommitted_feature(root: Path) -> Path:
44
+ """A freshly-drafted, UNcommitted feature folder on the current branch."""
45
+ fdir = root / ".specfuse" / "features" / f"{_FEAT_ID}-thing"
46
+ fdir.mkdir(parents=True)
47
+ (fdir / "PLAN.md").write_text(
48
+ f"---\nfeature_id: {_FEAT_ID}\ntitle: T\nbranch: {_BRANCH}\n"
49
+ f"roadmap_goal: t\nstatus: active\n---\n\n# Plan\n"
50
+ )
51
+ (fdir / "GATE-01.md").write_text("---\ngate: 1\nstatus: open\n---\n\n# Gate 1\n")
52
+ (fdir / "WU-01.md").write_text(
53
+ f"---\nid: {_FEAT_ID}/T01\ntype: implementation\n"
54
+ "status: pending\nattempts: 0\n---\n\n# WU\n"
55
+ )
56
+ return fdir
57
+
58
+
59
+ def _feat_fm() -> dict:
60
+ return {"feature_id": _FEAT_ID, "branch": _BRANCH}
61
+
62
+
63
+ class TestPrepareFeature(unittest.TestCase):
64
+
65
+ def setUp(self):
66
+ self._cwd = os.getcwd()
67
+
68
+ def tearDown(self):
69
+ os.chdir(self._cwd)
70
+
71
+ def test_prepare_creates_branch_and_commits(self):
72
+ with tempfile.TemporaryDirectory() as tmp:
73
+ root = Path(tmp)
74
+ _init_git(root)
75
+ fdir = _write_uncommitted_feature(root)
76
+ os.chdir(root)
77
+ rel = fdir.relative_to(root)
78
+ loop.prepare_feature(_feat_fm(), rel, _FEAT_ID)
79
+ # on the feature branch now
80
+ self.assertEqual(loop._current_branch(), _BRANCH)
81
+ # folder is tracked + tree clean (committed)
82
+ self.assertEqual(loop.untracked_feature_files(rel), [])
83
+ self.assertEqual(loop.feature_folder_tracked_modifications(rel), [])
84
+ tracked = subprocess.run(["git", "ls-files", str(rel)],
85
+ capture_output=True, text=True).stdout
86
+ self.assertIn("PLAN.md", tracked)
87
+
88
+ def test_committed_guard_message_suggests_prepare_and_branch(self):
89
+ """On the default branch, the untracked-folder refusal names --prepare
90
+ AND the `git checkout -b <branch>` step."""
91
+ with tempfile.TemporaryDirectory() as tmp:
92
+ root = Path(tmp)
93
+ _init_git(root)
94
+ fdir = _write_uncommitted_feature(root)
95
+ os.chdir(root)
96
+ rel = fdir.relative_to(root)
97
+ err = io.StringIO()
98
+ with self.assertRaises(SystemExit) as cm, redirect_stderr(err):
99
+ loop.require_feature_folder_committed(rel, _feat_fm(), _FEAT_ID)
100
+ msg = str(cm.exception)
101
+ self.assertIn("specfuse-loop --prepare", msg)
102
+ self.assertIn(f"git checkout -b {_BRANCH}", msg)
103
+
104
+ def test_guard_message_omits_checkout_when_on_feature_branch(self):
105
+ """When already on the feature branch, no checkout line — just commit."""
106
+ with tempfile.TemporaryDirectory() as tmp:
107
+ root = Path(tmp)
108
+ _init_git(root)
109
+ fdir = _write_uncommitted_feature(root)
110
+ os.chdir(root)
111
+ subprocess.run(["git", "checkout", "-q", "-b", _BRANCH], check=True)
112
+ rel = fdir.relative_to(root)
113
+ with self.assertRaises(SystemExit) as cm:
114
+ loop.require_feature_folder_committed(rel, _feat_fm(), _FEAT_ID)
115
+ msg = str(cm.exception)
116
+ self.assertIn("specfuse-loop --prepare", msg)
117
+ self.assertNotIn("git checkout -b", msg)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()
@@ -78,7 +78,7 @@ class TestRequireFeatureFolderCommitted(unittest.TestCase):
78
78
  files = loop.untracked_feature_files(rel)
79
79
  self.assertTrue(files, "the freshly written folder must read as untracked")
80
80
  with self.assertRaises(SystemExit):
81
- loop.require_feature_folder_committed(rel)
81
+ loop.require_feature_folder_committed(rel, {"branch": "feat/t"}, "FEAT-2026-9999")
82
82
 
83
83
  def test_committed_folder_passes(self):
84
84
  """A committed feature folder is a no-op (no exit)."""
@@ -92,7 +92,7 @@ class TestRequireFeatureFolderCommitted(unittest.TestCase):
92
92
  os.chdir(root)
93
93
  rel = fdir.relative_to(root)
94
94
  self.assertEqual(loop.untracked_feature_files(rel), [])
95
- loop.require_feature_folder_committed(rel) # must not raise
95
+ loop.require_feature_folder_committed(rel, {"branch": "feat/t"}, "FEAT-2026-9999") # must not raise
96
96
 
97
97
  def test_gitignored_work_dir_does_not_block(self):
98
98
  """Driver-managed gitignored paths (work/) must not count as untracked."""
@@ -111,7 +111,7 @@ class TestRequireFeatureFolderCommitted(unittest.TestCase):
111
111
  rel = fdir.relative_to(root)
112
112
  self.assertEqual(loop.untracked_feature_files(rel), [],
113
113
  "gitignored work/ must not be flagged")
114
- loop.require_feature_folder_committed(rel) # must not raise
114
+ loop.require_feature_folder_committed(rel, {"branch": "feat/t"}, "FEAT-2026-9999") # must not raise
115
115
 
116
116
 
117
117
  class TestWriteFrontmatterFieldHardening(unittest.TestCase):
@@ -0,0 +1,115 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """The driver stamps resolved execution metadata into a WU's frontmatter.
6
+
7
+ So the planned/effective model, effort, exit-oracle gate set, driver version,
8
+ and dispatch time are visible when you read the WU `.md` — not only on the
9
+ console or in events.jsonl. Written at dispatch (after status -> in_progress),
10
+ so they ride the WU's squash commit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ import unittest
18
+ from pathlib import Path
19
+
20
+ from tests._loop_loader import load_loop
21
+ from tests.test_driver_integration import (
22
+ integration_workspace,
23
+ write_minimal_feature,
24
+ _read_frontmatter,
25
+ )
26
+
27
+ loop = load_loop()
28
+
29
+
30
+ def _write_verification_yml(root: Path) -> None:
31
+ (root / ".specfuse/verification.yml").write_text(
32
+ "code:\n - name: noop\n command: \"true\"\n"
33
+ "doc:\n - name: noop\n command: \"true\"\n"
34
+ "plannext:\n - name: noop\n command: \"true\"\n"
35
+ )
36
+ subprocess.run(["git", "-C", str(root), "add", ".specfuse/verification.yml"],
37
+ check=True)
38
+ subprocess.run(["git", "-C", str(root), "commit", "-q", "-m", "verif"], check=True)
39
+
40
+
41
+ class TestWUExecutionMetadata(unittest.TestCase):
42
+
43
+ def setUp(self):
44
+ self._cwd = os.getcwd()
45
+ self._patches: list[tuple[str, object]] = []
46
+
47
+ def tearDown(self):
48
+ os.chdir(self._cwd)
49
+ for name, original in self._patches:
50
+ setattr(loop, name, original)
51
+
52
+ def _patch(self, name: str, replacement) -> None:
53
+ self._patches.append((name, getattr(loop, name)))
54
+ setattr(loop, name, replacement)
55
+
56
+ def test_dispatch_stamps_model_effort_gateset_driver_started(self):
57
+ with integration_workspace() as root:
58
+ os.chdir(root)
59
+ _write_verification_yml(root)
60
+ write_minimal_feature(
61
+ root, "FEAT-2026-9301", "exec-meta", "feat/exec-meta",
62
+ [("FEAT-2026-9301/T01", "implementation", "pending")],
63
+ )
64
+ self._patch("dispatch", lambda wu, fn, cost_tracking=True: ("(stub)\n", None))
65
+ self._patch("verify", lambda wu, fd, cfg=None: (True, "(stub)"))
66
+
67
+ rc = loop.run(None, dry_run=False)
68
+ self.assertEqual(rc, 0)
69
+
70
+ fdir = root / ".specfuse/features/FEAT-2026-9301-exec-meta"
71
+ fm = _read_frontmatter(fdir / "WU-T01.md")
72
+
73
+ # model = the WU's resolved model (the integration helper authors a
74
+ # concrete haiku id); effort defaults to EFFORT_BY_TYPE[implementation]
75
+ # = medium (helper sets no effort); gate_set = GATES_FOR_TYPE = code.
76
+ self.assertEqual(fm.get("model"), "claude-haiku-4-5-20251001")
77
+ self.assertEqual(fm.get("effort"), "medium")
78
+ self.assertEqual(fm.get("gate_set"), "code")
79
+ self.assertEqual(fm.get("driver_version"), loop.DRIVER_VERSION)
80
+ # started_at is a UTC ISO timestamp.
81
+ self.assertIn("started_at", fm)
82
+ self.assertRegex(fm["started_at"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
83
+
84
+ def test_author_model_effort_override_is_preserved(self):
85
+ """An explicit model/effort override survives the stamp (same value)."""
86
+ with integration_workspace() as root:
87
+ os.chdir(root)
88
+ _write_verification_yml(root)
89
+ write_minimal_feature(
90
+ root, "FEAT-2026-9302", "exec-ovr", "feat/exec-ovr",
91
+ [("FEAT-2026-9302/T01", "implementation", "pending")],
92
+ )
93
+ # Override the helper's default model with an alias + set effort.
94
+ wu_path = root / ".specfuse/features/FEAT-2026-9302-exec-ovr/WU-T01.md"
95
+ text = wu_path.read_text()
96
+ text = text.replace("model: claude-haiku-4-5-20251001\n",
97
+ "model: opus\neffort: high\n", 1)
98
+ wu_path.write_text(text)
99
+ subprocess.run(["git", "-C", str(root), "add", "."], check=True)
100
+ subprocess.run(["git", "-C", str(root), "commit", "-q", "-m", "override"],
101
+ check=True)
102
+
103
+ self._patch("dispatch", lambda wu, fn, cost_tracking=True: ("(stub)\n", None))
104
+ self._patch("verify", lambda wu, fd, cfg=None: (True, "(stub)"))
105
+ rc = loop.run(None, dry_run=False)
106
+ self.assertEqual(rc, 0)
107
+
108
+ fm = _read_frontmatter(
109
+ root / ".specfuse/features/FEAT-2026-9302-exec-ovr/WU-T01.md")
110
+ self.assertEqual(fm.get("model"), "opus")
111
+ self.assertEqual(fm.get("effort"), "high")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()
@@ -1 +0,0 @@
1
- 0.3.1
File without changes
File without changes
File without changes
File without changes