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.
- {specfuse_loop-0.3.2/specfuse_loop.egg-info → specfuse_loop-0.3.3}/PKG-INFO +1 -1
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/pyproject.toml +1 -1
- specfuse_loop-0.3.3/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/getting-started.md +10 -1
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/loop.py +77 -14
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/SOURCES.txt +1 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_arm_gate_edits_uncommitted.py +4 -4
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_orchestration.py +2 -2
- specfuse_loop-0.3.3/tests/test_prepare_feature.py +121 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_untracked_feature_folder.py +3 -3
- specfuse_loop-0.3.2/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/LICENSE +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/NOTICE +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/README.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/setup.cfg +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/lint_plan.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_wu_execution_metadata.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specfuse-loop"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.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
|
-
|
|
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.
|
|
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
|
|
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
|
|
730
|
-
f"`git reset --hard` will delete the folder mid-run (and
|
|
731
|
-
f"will crash reading a WU file that no longer exists).
|
|
732
|
-
f" {listed}\n"
|
|
733
|
-
|
|
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(
|
|
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
|
-
|
|
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__":
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/specfuse/loop/data/docs/concepts/ralph-lineage.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.2 → specfuse_loop-0.3.3}/tests/test_lint_correlation_id_close_intermediate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|