specfuse-loop 0.3.2__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.2/specfuse_loop.egg-info → specfuse_loop-0.3.3}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.2 → 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.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/getting-started.md +10 -1
  5. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/loop.py +77 -14
  6. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3/specfuse_loop.egg-info}/PKG-INFO +1 -1
  7. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  8. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_arm_gate_edits_uncommitted.py +4 -4
  9. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_orchestration.py +2 -2
  10. specfuse_loop-0.3.3/tests/test_prepare_feature.py +121 -0
  11. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/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.3}/LICENSE +0 -0
  14. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/NOTICE +0 -0
  15. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/README.md +0 -0
  16. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/setup.cfg +0 -0
  17. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/__init__.py +0 -0
  18. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/_miniyaml.py +0 -0
  19. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/adopt_feature.py +0 -0
  20. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  21. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  22. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  23. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/methodology.md +0 -0
  24. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/skills.md +0 -0
  25. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/gitignore.snippet +0 -0
  26. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/roadmap.template.md +0 -0
  27. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  28. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/never-touch.md +0 -0
  29. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/result-contract.md +0 -0
  30. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  31. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/GATE.template.md +0 -0
  32. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  33. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/WU.template.md +0 -0
  34. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/verification.yml.example +0 -0
  35. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/gate_eval.py +0 -0
  36. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/gh_backend.py +0 -0
  37. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/gh_features.py +0 -0
  38. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/lint_plan.py +0 -0
  39. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/scaffold.py +0 -0
  40. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/validate_event.py +0 -0
  41. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  42. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/entry_points.txt +0 -0
  43. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/requires.txt +0 -0
  44. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/top_level.txt +0 -0
  45. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_adopt_feature.py +0 -0
  46. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_attempt_outcome_emission.py +0 -0
  47. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync.py +0 -0
  48. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync_consent.py +0 -0
  49. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync_firstrun.py +0 -0
  50. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync_plugin.py +0 -0
  51. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_backend.py +0 -0
  52. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  53. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  54. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_closing_deliverable_guard.py +0 -0
  55. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_cost_tracking.py +0 -0
  56. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_deliverable_presence_gate.py +0 -0
  57. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_doctor.py +0 -0
  58. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_driver_integration.py +0 -0
  59. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_driver_lock.py +0 -0
  60. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_duration_tracking.py +0 -0
  61. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_empty_files_escalation.py +0 -0
  62. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_ensure_feature_branch.py +0 -0
  63. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_extra_gates.py +0 -0
  64. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_force_full_close.py +0 -0
  65. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval.py +0 -0
  66. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval_calibration.py +0 -0
  67. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  68. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval_terminal_wiring.py +0 -0
  69. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gh_backend.py +0 -0
  70. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gh_features.py +0 -0
  71. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_git_env_isolation.py +0 -0
  72. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_hashed_denylist.py +0 -0
  73. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_hashed_denylist_ci.py +0 -0
  74. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_init_integration.py +0 -0
  75. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_leak_findings_redaction.py +0 -0
  76. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_leak_scan.py +0 -0
  77. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_leak_scan_content.py +0 -0
  78. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  79. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lifecycle_integration.py +0 -0
  80. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_bare_produces_path.py +0 -0
  81. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_close_intermediate.py +0 -0
  82. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_close_wu.py +0 -0
  83. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id.py +0 -0
  84. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  85. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_oracle_env.py +0 -0
  86. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_plan_errors.py +0 -0
  87. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_plan_next_draft.py +0 -0
  88. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_produces_driver_helper.py +0 -0
  89. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_sections.py +0 -0
  90. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  91. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_auto_archive.py +0 -0
  92. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_caveman_preamble.py +0 -0
  93. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_close_intermediate.py +0 -0
  94. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_defaults_by_type.py +0 -0
  95. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_effort.py +0 -0
  96. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_failure_note_cap.py +0 -0
  97. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_files_changed_guard.py +0 -0
  98. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_gate_budget.py +0 -0
  99. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_model_alias.py +0 -0
  100. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_post_pass_invariant.py +0 -0
  101. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_reset_preserving_events.py +0 -0
  102. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_smoke_runner.py +0 -0
  103. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_unsandboxed.py +0 -0
  104. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_zero_token_guard.py +0 -0
  105. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_migrate_legacy.py +0 -0
  106. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_miniyaml_equivalence.py +0 -0
  107. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_miniyaml_negative.py +0 -0
  108. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_planned_cost_lint.py +0 -0
  109. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_produces_field.py +0 -0
  110. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_result_block.py +0 -0
  111. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_roadmap_add_skill.py +0 -0
  112. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_roadmap_archive_skill.py +0 -0
  113. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_roadmap_row_parser.py +0 -0
  114. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_data_in_sync.py +0 -0
  115. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_docs.py +0 -0
  116. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_init.py +0 -0
  117. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_manifest.py +0 -0
  118. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_resources.py +0 -0
  119. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_seed_sanity.py +0 -0
  120. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_upgrade.py +0 -0
  121. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_wiring.py +0 -0
  122. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_squash_commit_hook_crash.py +0 -0
  123. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_template_closing_shapes.py +0 -0
  124. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_terminal_flip_ownership.py +0 -0
  125. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_terminal_flips.py +0 -0
  126. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_upgrade_integration.py +0 -0
  127. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_validate_event.py +0 -0
  128. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_verdict_coupling.py +0 -0
  129. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_verify_empty_gate_set.py +0 -0
  130. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_version_consistency.py +0 -0
  131. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_version_skew.py +0 -0
  132. {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/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.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.2"
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
 
@@ -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.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:
@@ -3952,11 +4009,17 @@ def main() -> int:
3952
4009
  "path for the named feature. Must match the feature being processed.")
3953
4010
  ap.add_argument("--no-autosync", action="store_true",
3954
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.")
3955
4017
  args = ap.parse_args()
3956
4018
  if not FEATURES_DIR.exists():
3957
4019
  sys.exit(f"No {FEATURES_DIR}. Run from your repo root.")
3958
4020
  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)
4021
+ return run(args.feature, args.dry_run, force_full_close=args.force_full_close,
4022
+ prepare=args.prepare)
3960
4023
 
3961
4024
 
3962
4025
  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.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
@@ -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):
@@ -1 +0,0 @@
1
- 0.3.2
File without changes
File without changes
File without changes
File without changes