specfuse-loop 0.3.2__tar.gz → 0.3.4__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.2/specfuse_loop.egg-info → specfuse_loop-0.3.4}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.4/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/docs/getting-started.md +10 -1
  5. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/loop.py +98 -17
  6. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  8. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_arm_gate_edits_uncommitted.py +4 -4
  9. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_orchestration.py +2 -2
  10. specfuse_loop-0.3.4/tests/test_prepare_feature.py +159 -0
  11. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_untracked_feature_folder.py +3 -3
  12. specfuse_loop-0.3.2/specfuse/loop/data/VERSION +0 -1
  13. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/LICENSE +0 -0
  14. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/NOTICE +0 -0
  15. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/README.md +0 -0
  16. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/setup.cfg +0 -0
  17. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/__init__.py +0 -0
  18. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/_miniyaml.py +0 -0
  19. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/adopt_feature.py +0 -0
  20. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  21. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  22. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  23. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/docs/methodology.md +0 -0
  24. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/docs/skills.md +0 -0
  25. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/gitignore.snippet +0 -0
  26. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/roadmap.template.md +0 -0
  27. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  28. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/rules/never-touch.md +0 -0
  29. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/rules/result-contract.md +0 -0
  30. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  31. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/templates/GATE.template.md +0 -0
  32. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  33. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/templates/WU.template.md +0 -0
  34. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/data/verification.yml.example +0 -0
  35. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/gate_eval.py +0 -0
  36. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/gh_backend.py +0 -0
  37. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/gh_features.py +0 -0
  38. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/lint_plan.py +0 -0
  39. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/scaffold.py +0 -0
  40. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse/loop/validate_event.py +0 -0
  41. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  42. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse_loop.egg-info/entry_points.txt +0 -0
  43. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse_loop.egg-info/requires.txt +0 -0
  44. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/specfuse_loop.egg-info/top_level.txt +0 -0
  45. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_adopt_feature.py +0 -0
  46. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_attempt_outcome_emission.py +0 -0
  47. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_autosync.py +0 -0
  48. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_autosync_consent.py +0 -0
  49. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_autosync_firstrun.py +0 -0
  50. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_autosync_plugin.py +0 -0
  51. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_backend.py +0 -0
  52. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  53. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  54. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_closing_deliverable_guard.py +0 -0
  55. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_cost_tracking.py +0 -0
  56. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_deliverable_presence_gate.py +0 -0
  57. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_doctor.py +0 -0
  58. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_driver_integration.py +0 -0
  59. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_driver_lock.py +0 -0
  60. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_duration_tracking.py +0 -0
  61. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_empty_files_escalation.py +0 -0
  62. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_ensure_feature_branch.py +0 -0
  63. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_extra_gates.py +0 -0
  64. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_force_full_close.py +0 -0
  65. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_gate_eval.py +0 -0
  66. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_gate_eval_calibration.py +0 -0
  67. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  68. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_gate_eval_terminal_wiring.py +0 -0
  69. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_gh_backend.py +0 -0
  70. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_gh_features.py +0 -0
  71. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_git_env_isolation.py +0 -0
  72. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_hashed_denylist.py +0 -0
  73. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_hashed_denylist_ci.py +0 -0
  74. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_init_integration.py +0 -0
  75. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_leak_findings_redaction.py +0 -0
  76. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_leak_scan.py +0 -0
  77. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_leak_scan_content.py +0 -0
  78. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  79. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lifecycle_integration.py +0 -0
  80. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_bare_produces_path.py +0 -0
  81. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_close_intermediate.py +0 -0
  82. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_close_wu.py +0 -0
  83. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_correlation_id.py +0 -0
  84. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  85. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_oracle_env.py +0 -0
  86. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_plan_errors.py +0 -0
  87. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_plan_next_draft.py +0 -0
  88. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_produces_driver_helper.py +0 -0
  89. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_sections.py +0 -0
  90. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  91. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_auto_archive.py +0 -0
  92. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_caveman_preamble.py +0 -0
  93. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_close_intermediate.py +0 -0
  94. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_defaults_by_type.py +0 -0
  95. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_effort.py +0 -0
  96. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_failure_note_cap.py +0 -0
  97. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_files_changed_guard.py +0 -0
  98. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_gate_budget.py +0 -0
  99. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_model_alias.py +0 -0
  100. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_post_pass_invariant.py +0 -0
  101. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_reset_preserving_events.py +0 -0
  102. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_smoke_runner.py +0 -0
  103. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_unsandboxed.py +0 -0
  104. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_loop_zero_token_guard.py +0 -0
  105. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_migrate_legacy.py +0 -0
  106. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_miniyaml_equivalence.py +0 -0
  107. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_miniyaml_negative.py +0 -0
  108. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_planned_cost_lint.py +0 -0
  109. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_produces_field.py +0 -0
  110. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_result_block.py +0 -0
  111. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_roadmap_add_skill.py +0 -0
  112. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_roadmap_archive_skill.py +0 -0
  113. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_roadmap_row_parser.py +0 -0
  114. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_data_in_sync.py +0 -0
  115. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_docs.py +0 -0
  116. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_init.py +0 -0
  117. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_manifest.py +0 -0
  118. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_resources.py +0 -0
  119. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_seed_sanity.py +0 -0
  120. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_upgrade.py +0 -0
  121. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_scaffold_wiring.py +0 -0
  122. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_squash_commit_hook_crash.py +0 -0
  123. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_template_closing_shapes.py +0 -0
  124. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_terminal_flip_ownership.py +0 -0
  125. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_terminal_flips.py +0 -0
  126. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_upgrade_integration.py +0 -0
  127. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_validate_event.py +0 -0
  128. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_verdict_coupling.py +0 -0
  129. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_verify_empty_gate_set.py +0 -0
  130. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_version_consistency.py +0 -0
  131. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_version_skew.py +0 -0
  132. {specfuse_loop-0.3.2 → specfuse_loop-0.3.4}/tests/test_wu_execution_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.2
3
+ Version: 0.3.4
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.2"
7
+ version = "0.3.4"
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.4
@@ -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
 
@@ -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.2"
65
+ DRIVER_VERSION = "0.3.4"
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,40 @@ 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 # …then run",
732
+ " specfuse-loop --prepare-only # …then stop, so you can review first",
733
+ "",
734
+ "Or do it manually:",
735
+ ]
736
+ if not on_branch:
737
+ lines.append(f" git checkout -b {branch}")
738
+ lines.append(f" git add {feature_dir}")
739
+ lines.append(f" git commit -m 'chore: scaffold feature {feature_id}'")
740
+ return "\n".join(lines)
741
+
742
+
743
+ def require_feature_folder_committed(
744
+ feature_dir: "Path", feat_fm: dict, feature_id: str,
745
+ ) -> None:
713
746
  """Hard-stop if the dispatched feature folder has untracked files (#71).
714
747
 
715
748
  The per-attempt `git reset --hard head_before` rolls tracked files back to
@@ -726,12 +759,11 @@ def require_feature_folder_committed(feature_dir: "Path") -> None:
726
759
  listed = "\n ".join(sorted(untracked))
727
760
  sys.exit(
728
761
  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."
762
+ f"create the feature branch and commit them before running, or the "
763
+ f"per-attempt `git reset --hard` will delete the folder mid-run (and "
764
+ f"the driver will crash reading a WU file that no longer exists). "
765
+ f"Untracked:\n {listed}\n"
766
+ + _branch_prep_hint(feature_dir, feat_fm, feature_id)
735
767
  )
736
768
 
737
769
 
@@ -786,7 +818,9 @@ def feature_folder_tracked_modifications(feature_dir: "Path") -> list[str]:
786
818
  return mods
787
819
 
788
820
 
789
- def require_feature_folder_unmodified(feature_dir: "Path") -> None:
821
+ def require_feature_folder_unmodified(
822
+ feature_dir: "Path", feat_fm: dict, feature_id: str,
823
+ ) -> None:
790
824
  """Hard-stop on uncommitted arm-gate / WU-revision edits before dispatch (#74).
791
825
 
792
826
  arm-gate (and any manual WU-body revision at a gate boundary) writes
@@ -808,11 +842,30 @@ def require_feature_folder_unmodified(feature_dir: "Path") -> None:
808
842
  f"`git reset --hard` will discard them (your armed WU statuses and "
809
843
  f"any acceptance-criteria revisions would silently revert). "
810
844
  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."
845
+ + _branch_prep_hint(feature_dir, feat_fm, feature_id)
813
846
  )
814
847
 
815
848
 
849
+ def prepare_feature(feat_fm: dict, feature_dir: "Path", feature_id: str) -> None:
850
+ """`--prepare`: put the feature folder on its declared branch and commit it.
851
+
852
+ `draft-feature` leaves the folder uncommitted on whatever branch you were on
853
+ (usually the default). This creates/checks out the feature's branch (from
854
+ PLAN.md `branch:`, carrying the untracked folder along) and commits the
855
+ folder, so the pre-flight guards pass and the run can start. Idempotent: a
856
+ no-op commit when there is nothing to commit.
857
+ """
858
+ branch = feat_fm.get("branch")
859
+ ensure_feature_branch(feat_fm, feature_dir)
860
+ git("add", "--", str(feature_dir))
861
+ staged = git("status", "--porcelain", "--", str(feature_dir))
862
+ if staged:
863
+ git("commit", "-m", f"chore: scaffold feature {feature_id}")
864
+ print(f"Prepared: committed feature folder on branch '{branch}'.")
865
+ else:
866
+ print(f"Prepare: feature folder already committed on branch '{branch}'.")
867
+
868
+
816
869
  def _checked_checkout(checkout_args: list[str], action: str) -> str:
817
870
  """Run a `git checkout ...` guarded: on non-zero exit raise FeatureBranchError
818
871
  carrying git's stderr, instead of a bare CalledProcessError that hides it.
@@ -874,9 +927,15 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
874
927
  ).returncode == 0
875
928
  if not is_ancestor:
876
929
  raise FeatureBranchError(
877
- f"branch '{branch}' exists and diverges from HEAD (not an "
878
- f"ancestor). Refusing to silently check out a stale branch; "
879
- f"resolve manually (rebase, recreate, or delete it) and re-run."
930
+ f"branch '{branch}' already exists and has diverged from your "
931
+ f"current branch '{current}' (it carries commits HEAD does not). "
932
+ f"Refusing to silently check it out that could build on a stale "
933
+ f"base or lose work. Pick one, then re-run:\n"
934
+ f" - resume that branch's work: git checkout {branch}\n"
935
+ f" - rebase it onto here: git checkout {branch} && "
936
+ f"git rebase {current}\n"
937
+ f" - discard it (work not needed): git branch -D {branch} "
938
+ f"(the loop recreates it from HEAD)"
880
939
  )
881
940
  _checked_checkout(["checkout", branch], f"checkout of existing branch '{branch}'")
882
941
  print(f"Switched to feature branch '{branch}' (was on '{current}').")
@@ -2921,6 +2980,8 @@ def run(
2921
2980
  feature_arg: str | None,
2922
2981
  dry_run: bool,
2923
2982
  force_full_close: str | None = None,
2983
+ prepare: bool = False,
2984
+ prepare_only: bool = False,
2924
2985
  ) -> int:
2925
2986
  # Fail-fast on a malformed verification.yml BEFORE we touch any WU state.
2926
2987
  # The per-gate `verify()` call lazy-loads the same file; if it's malformed,
@@ -2974,6 +3035,16 @@ def run(
2974
3035
  )
2975
3036
  return 1
2976
3037
  require_git_ready()
3038
+ # --prepare / --prepare-only: create the feature branch + commit the
3039
+ # folder up front so the guards below pass (the do-it-for-me path
3040
+ # draft-feature can't take). --prepare-only stops after, so you can
3041
+ # review the commit before the loop dispatches anything.
3042
+ if prepare or prepare_only:
3043
+ prepare_feature(feat_fm, feature_dir, feature_id)
3044
+ if prepare_only:
3045
+ print("Prepared: feature is on its branch and committed. "
3046
+ "Re-run `specfuse-loop` to start the gate.")
3047
+ return 0
2977
3048
  # Pre-flight guards — both run BEFORE ensure_feature_branch so the
2978
3049
  # refusal happens before any branch mutation, and both protect against
2979
3050
  # the per-attempt `git reset --hard` destroying uncommitted state:
@@ -2981,8 +3052,8 @@ def run(
2981
3052
  # deleted by the reset (and crash the next frontmatter write).
2982
3053
  # #74 — uncommitted arm-gate / WU-revision edits (armed statuses, AC
2983
3054
  # revisions) would be silently discarded by the reset.
2984
- require_feature_folder_committed(feature_dir)
2985
- require_feature_folder_unmodified(feature_dir)
3055
+ require_feature_folder_committed(feature_dir, feat_fm, feature_id)
3056
+ require_feature_folder_unmodified(feature_dir, feat_fm, feature_id)
2986
3057
  ensure_feature_branch(feat_fm, feature_dir)
2987
3058
 
2988
3059
  try:
@@ -3952,11 +4023,21 @@ def main() -> int:
3952
4023
  "path for the named feature. Must match the feature being processed.")
3953
4024
  ap.add_argument("--no-autosync", action="store_true",
3954
4025
  help="Skip auto-sync entirely (no scaffold create or overlay).")
4026
+ ap.add_argument("--prepare", action="store_true",
4027
+ help="Create the active feature's branch (from PLAN.md "
4028
+ "'branch:') and commit its folder, then run — the do-it-for-me "
4029
+ "path after /draft-feature, which leaves the folder "
4030
+ "uncommitted on the current branch.")
4031
+ ap.add_argument("--prepare-only", action="store_true",
4032
+ help="Like --prepare (create branch + commit the folder) but "
4033
+ "STOP afterwards without dispatching — review the commit, then "
4034
+ "re-run `specfuse-loop` to start.")
3955
4035
  args = ap.parse_args()
3956
4036
  if not FEATURES_DIR.exists():
3957
4037
  sys.exit(f"No {FEATURES_DIR}. Run from your repo root.")
3958
4038
  auto_sync(dry_run=args.dry_run, no_autosync=args.no_autosync)
3959
- return run(args.feature, args.dry_run, force_full_close=args.force_full_close)
4039
+ return run(args.feature, args.dry_run, force_full_close=args.force_full_close,
4040
+ prepare=args.prepare, prepare_only=args.prepare_only)
3960
4041
 
3961
4042
 
3962
4043
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -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, prepare_only=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, prepare_only=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,159 @@
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
+ specfuse = root / ".specfuse"
40
+ specfuse.mkdir()
41
+ (specfuse / "verification.yml").write_text(
42
+ "code:\n - name: noop\n command: \"true\"\n"
43
+ "doc:\n - name: noop\n command: \"true\"\n"
44
+ "plannext:\n - name: noop\n command: \"true\"\n"
45
+ )
46
+ subprocess.run(["git", "-C", str(root), "add", "."], check=True)
47
+ subprocess.run(["git", "-C", str(root), "commit", "-q", "-m", "init"], check=True)
48
+
49
+
50
+ def _write_uncommitted_feature(root: Path) -> Path:
51
+ """A freshly-drafted, UNcommitted feature folder on the current branch."""
52
+ fdir = root / ".specfuse" / "features" / f"{_FEAT_ID}-thing"
53
+ fdir.mkdir(parents=True)
54
+ (fdir / "PLAN.md").write_text(
55
+ f"---\nfeature_id: {_FEAT_ID}\ntitle: T\nbranch: {_BRANCH}\n"
56
+ f"roadmap_goal: t\nstatus: active\n---\n\n# Plan\n\n```yaml\n"
57
+ f"gates:\n - gate: 1\n file: GATE-01.md\n work_units:\n"
58
+ f" - id: {_FEAT_ID}/T01\n file: WU-01.md\n"
59
+ f" depends_on: []\n```\n"
60
+ )
61
+ (fdir / "GATE-01.md").write_text("---\ngate: 1\nstatus: open\n---\n\n# Gate 1\n")
62
+ (fdir / "WU-01.md").write_text(
63
+ f"---\nid: {_FEAT_ID}/T01\ntype: implementation\n"
64
+ "status: pending\nattempts: 0\n---\n\n# WU\n"
65
+ )
66
+ return fdir
67
+
68
+
69
+ def _feat_fm() -> dict:
70
+ return {"feature_id": _FEAT_ID, "branch": _BRANCH}
71
+
72
+
73
+ class TestPrepareFeature(unittest.TestCase):
74
+
75
+ def setUp(self):
76
+ self._cwd = os.getcwd()
77
+
78
+ def tearDown(self):
79
+ os.chdir(self._cwd)
80
+
81
+ def test_prepare_creates_branch_and_commits(self):
82
+ with tempfile.TemporaryDirectory() as tmp:
83
+ root = Path(tmp)
84
+ _init_git(root)
85
+ fdir = _write_uncommitted_feature(root)
86
+ os.chdir(root)
87
+ rel = fdir.relative_to(root)
88
+ loop.prepare_feature(_feat_fm(), rel, _FEAT_ID)
89
+ # on the feature branch now
90
+ self.assertEqual(loop._current_branch(), _BRANCH)
91
+ # folder is tracked + tree clean (committed)
92
+ self.assertEqual(loop.untracked_feature_files(rel), [])
93
+ self.assertEqual(loop.feature_folder_tracked_modifications(rel), [])
94
+ tracked = subprocess.run(["git", "ls-files", str(rel)],
95
+ capture_output=True, text=True).stdout
96
+ self.assertIn("PLAN.md", tracked)
97
+
98
+ def test_prepare_only_prepares_then_stops(self):
99
+ """--prepare-only branches + commits but does NOT dispatch (rc=0)."""
100
+ with tempfile.TemporaryDirectory() as tmp:
101
+ root = Path(tmp)
102
+ _init_git(root)
103
+ _write_uncommitted_feature(root)
104
+ os.chdir(root)
105
+
106
+ called = {"dispatch": False}
107
+
108
+ def _no_dispatch(*a, **k):
109
+ called["dispatch"] = True
110
+ raise AssertionError("must not dispatch under --prepare-only")
111
+
112
+ orig = loop.dispatch
113
+ loop.dispatch = _no_dispatch
114
+ try:
115
+ rc = loop.run(None, dry_run=False, prepare_only=True)
116
+ finally:
117
+ loop.dispatch = orig
118
+
119
+ self.assertEqual(rc, 0)
120
+ self.assertFalse(called["dispatch"])
121
+ self.assertEqual(loop._current_branch(), _BRANCH)
122
+ # folder committed (tree clean)
123
+ rel = Path(".specfuse/features") / f"{_FEAT_ID}-thing"
124
+ self.assertEqual(loop.untracked_feature_files(rel), [])
125
+
126
+ def test_committed_guard_message_suggests_prepare_and_branch(self):
127
+ """On the default branch, the untracked-folder refusal names --prepare
128
+ AND the `git checkout -b <branch>` step."""
129
+ with tempfile.TemporaryDirectory() as tmp:
130
+ root = Path(tmp)
131
+ _init_git(root)
132
+ fdir = _write_uncommitted_feature(root)
133
+ os.chdir(root)
134
+ rel = fdir.relative_to(root)
135
+ err = io.StringIO()
136
+ with self.assertRaises(SystemExit) as cm, redirect_stderr(err):
137
+ loop.require_feature_folder_committed(rel, _feat_fm(), _FEAT_ID)
138
+ msg = str(cm.exception)
139
+ self.assertIn("specfuse-loop --prepare", msg)
140
+ self.assertIn(f"git checkout -b {_BRANCH}", msg)
141
+
142
+ def test_guard_message_omits_checkout_when_on_feature_branch(self):
143
+ """When already on the feature branch, no checkout line — just commit."""
144
+ with tempfile.TemporaryDirectory() as tmp:
145
+ root = Path(tmp)
146
+ _init_git(root)
147
+ fdir = _write_uncommitted_feature(root)
148
+ os.chdir(root)
149
+ subprocess.run(["git", "checkout", "-q", "-b", _BRANCH], check=True)
150
+ rel = fdir.relative_to(root)
151
+ with self.assertRaises(SystemExit) as cm:
152
+ loop.require_feature_folder_committed(rel, _feat_fm(), _FEAT_ID)
153
+ msg = str(cm.exception)
154
+ self.assertIn("specfuse-loop --prepare", msg)
155
+ self.assertNotIn("git checkout -b", msg)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ 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):
@@ -1 +0,0 @@
1
- 0.3.2
File without changes
File without changes
File without changes
File without changes