specfuse-loop 0.3.11__tar.gz → 0.3.12__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 (146) hide show
  1. {specfuse_loop-0.3.11/specfuse_loop.egg-info → specfuse_loop-0.3.12}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.12/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/loop.py +100 -3
  5. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12/specfuse_loop.egg-info}/PKG-INFO +1 -1
  6. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  7. specfuse_loop-0.3.12/tests/test_prepare_scaffold_sync.py +172 -0
  8. specfuse_loop-0.3.11/specfuse/loop/data/VERSION +0 -1
  9. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/LICENSE +0 -0
  10. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/NOTICE +0 -0
  11. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/README.md +0 -0
  12. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/setup.cfg +0 -0
  13. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/__init__.py +0 -0
  14. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/_miniyaml.py +0 -0
  15. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/adopt_feature.py +0 -0
  16. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  17. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  18. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  19. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/docs/getting-started.md +0 -0
  20. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/docs/methodology.md +0 -0
  21. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/docs/skills.md +0 -0
  22. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/gitignore.snippet +0 -0
  23. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/roadmap.template.md +0 -0
  24. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  25. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/rules/never-touch.md +0 -0
  26. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/rules/result-contract.md +0 -0
  27. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  28. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  29. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/schemas/event.schema.json +0 -0
  30. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  31. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  32. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  33. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  34. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/templates/GATE.template.md +0 -0
  35. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  36. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/templates/WU.template.md +0 -0
  37. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/data/verification.yml.example +0 -0
  38. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/gate_eval.py +0 -0
  39. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/gh_backend.py +0 -0
  40. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/gh_features.py +0 -0
  41. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/lint_plan.py +0 -0
  42. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/scaffold.py +0 -0
  43. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse/loop/validate_event.py +0 -0
  44. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  45. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse_loop.egg-info/entry_points.txt +0 -0
  46. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse_loop.egg-info/requires.txt +0 -0
  47. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/specfuse_loop.egg-info/top_level.txt +0 -0
  48. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_adopt_feature.py +0 -0
  49. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  50. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_attempt_outcome_emission.py +0 -0
  51. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_autosync.py +0 -0
  52. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_autosync_consent.py +0 -0
  53. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_autosync_firstrun.py +0 -0
  54. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_autosync_plugin.py +0 -0
  55. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_backend.py +0 -0
  56. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  57. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  58. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_closing_deliverable_guard.py +0 -0
  59. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_cost_tracking.py +0 -0
  60. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_degraded_oracle.py +0 -0
  61. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_deliverable_presence_gate.py +0 -0
  62. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_doctor.py +0 -0
  63. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_driver_integration.py +0 -0
  64. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_driver_lock.py +0 -0
  65. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_duration_tracking.py +0 -0
  66. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_empty_files_escalation.py +0 -0
  67. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_ensure_feature_branch.py +0 -0
  68. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_events_redaction.py +0 -0
  69. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_extra_gates.py +0 -0
  70. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_force_full_close.py +0 -0
  71. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_gate_eval.py +0 -0
  72. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_gate_eval_calibration.py +0 -0
  73. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  74. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_gate_eval_terminal_wiring.py +0 -0
  75. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_gh_backend.py +0 -0
  76. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_gh_features.py +0 -0
  77. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_git_env_isolation.py +0 -0
  78. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_hashed_denylist.py +0 -0
  79. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_hashed_denylist_ci.py +0 -0
  80. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_init_integration.py +0 -0
  81. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_leak_findings_redaction.py +0 -0
  82. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_leak_scan.py +0 -0
  83. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_leak_scan_content.py +0 -0
  84. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_learnings_query.py +0 -0
  85. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  86. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lifecycle_integration.py +0 -0
  87. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_bare_produces_path.py +0 -0
  88. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_close_intermediate.py +0 -0
  89. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_close_wu.py +0 -0
  90. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_correlation_id.py +0 -0
  91. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  92. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_oracle_env.py +0 -0
  93. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_plan_errors.py +0 -0
  94. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_plan_next_draft.py +0 -0
  95. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_plan_relative_import.py +0 -0
  96. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_produces_driver_helper.py +0 -0
  97. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_sections.py +0 -0
  98. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  99. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_auto_archive.py +0 -0
  100. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_caveman_preamble.py +0 -0
  101. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_close_intermediate.py +0 -0
  102. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_defaults_by_type.py +0 -0
  103. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_effort.py +0 -0
  104. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_failure_note_cap.py +0 -0
  105. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_files_changed_guard.py +0 -0
  106. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_gate_budget.py +0 -0
  107. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_model_alias.py +0 -0
  108. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_orchestration.py +0 -0
  109. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_post_pass_invariant.py +0 -0
  110. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_reset_preserving_events.py +0 -0
  111. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_smoke_runner.py +0 -0
  112. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_unsandboxed.py +0 -0
  113. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_loop_zero_token_guard.py +0 -0
  114. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_migrate_legacy.py +0 -0
  115. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_miniyaml_equivalence.py +0 -0
  116. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_miniyaml_negative.py +0 -0
  117. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_planned_cost_lint.py +0 -0
  118. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_prepare_feature.py +0 -0
  119. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_produces_field.py +0 -0
  120. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_result_block.py +0 -0
  121. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_roadmap_add_skill.py +0 -0
  122. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_roadmap_archive_skill.py +0 -0
  123. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_roadmap_row_parser.py +0 -0
  124. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_data_in_sync.py +0 -0
  125. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_doc_hygiene.py +0 -0
  126. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_docs.py +0 -0
  127. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_init.py +0 -0
  128. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_manifest.py +0 -0
  129. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_resources.py +0 -0
  130. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_seed_sanity.py +0 -0
  131. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_upgrade.py +0 -0
  132. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_scaffold_wiring.py +0 -0
  133. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_skills_vendored_in_sync.py +0 -0
  134. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_squash_commit_hook_crash.py +0 -0
  135. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_template_closing_shapes.py +0 -0
  136. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_terminal_flip_ownership.py +0 -0
  137. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_terminal_flips.py +0 -0
  138. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_untracked_feature_folder.py +0 -0
  139. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_upgrade_integration.py +0 -0
  140. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_upgrade_merge_gate.py +0 -0
  141. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_validate_event.py +0 -0
  142. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_verdict_coupling.py +0 -0
  143. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_verify_empty_gate_set.py +0 -0
  144. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_version_consistency.py +0 -0
  145. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/tests/test_version_skew.py +0 -0
  146. {specfuse_loop-0.3.11 → specfuse_loop-0.3.12}/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.11
3
+ Version: 0.3.12
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.11"
7
+ version = "0.3.12"
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.12
@@ -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.11"
65
+ DRIVER_VERSION = "0.3.12"
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
@@ -766,6 +766,84 @@ def _current_branch() -> str:
766
766
  return git("branch", "--show-current")
767
767
 
768
768
 
769
+ def _default_branch() -> "str | None":
770
+ """The repo's default branch, or None if undeterminable.
771
+
772
+ Prefers origin's HEAD symref (`origin/main` -> `main`); falls back to a
773
+ local `main`/`master` when there is no remote. None when neither resolves —
774
+ callers then treat the current branch as non-default (auto_sync commits its
775
+ scaffold sync rather than leaving it dangling).
776
+ """
777
+ ref = subprocess.run(
778
+ ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
779
+ capture_output=True, text=True,
780
+ )
781
+ if ref.returncode == 0 and ref.stdout.strip():
782
+ name = ref.stdout.strip()
783
+ return name[len("origin/"):] if name.startswith("origin/") else name
784
+ for cand in ("main", "master"):
785
+ if subprocess.run(
786
+ ["git", "rev-parse", "--verify", "--quiet", cand],
787
+ capture_output=True, text=True,
788
+ ).returncode == 0:
789
+ return cand
790
+ return None
791
+
792
+
793
+ # Paths the scaffold overlay owns — auto_sync writes these on upgrade. They are
794
+ # the driver's, not the user's edits: they may carry onto a feature branch and
795
+ # are folded into the --prepare scaffold commit. Mirrors scaffold.py's versioned
796
+ # overlay set (templates/, rules/, docs/, schemas/) plus the meta files.
797
+ _SCAFFOLD_MANAGED_PREFIXES: tuple[str, ...] = (
798
+ ".specfuse/templates/", ".specfuse/rules/",
799
+ ".specfuse/docs/", ".specfuse/schemas/",
800
+ )
801
+ _SCAFFOLD_MANAGED_EXACT: frozenset[str] = frozenset({
802
+ ".specfuse/VERSION", ".specfuse/.scaffold-manifest",
803
+ ".specfuse/verification.yml.example",
804
+ })
805
+
806
+
807
+ def _is_scaffold_managed(path: str) -> bool:
808
+ return (path in _SCAFFOLD_MANAGED_EXACT
809
+ or any(path.startswith(p) for p in _SCAFFOLD_MANAGED_PREFIXES))
810
+
811
+
812
+ def _scaffold_managed_dirty() -> set[str]:
813
+ """Tracked-dirty paths the scaffold overlay owns (subset of the dirty set)."""
814
+ return {p for p in _tracked_dirty_paths() if _is_scaffold_managed(p)}
815
+
816
+
817
+ def _persist_scaffold_sync(installed: str) -> None:
818
+ """After auto_sync overlays a newer scaffold, keep the tree clean for the
819
+ rest of the run. On a non-default branch, commit the scaffold-managed
820
+ changes (`chore(loop): sync scaffold to X.Y.Z`). On the DEFAULT branch, leave
821
+ them and print guidance — committing scaffold churn onto the default branch
822
+ is undesirable; --prepare (or the next ensure_feature_branch) carries them
823
+ onto the feature branch and folds them into its scaffold commit instead.
824
+ """
825
+ dirty = _scaffold_managed_dirty()
826
+ if not dirty:
827
+ return
828
+ branch = _current_branch()
829
+ on_default = (not branch) or branch == _default_branch()
830
+ if on_default:
831
+ print(
832
+ f"auto_sync: scaffold upgraded to {installed}; leaving "
833
+ f"{len(dirty)} scaffold file(s) uncommitted on default branch "
834
+ f"'{branch or '(detached)'}'. --prepare will carry them onto the "
835
+ f"feature branch, or commit them yourself.",
836
+ file=sys.stderr,
837
+ )
838
+ return
839
+ commit_bookkeeping(sorted(dirty), f"chore(loop): sync scaffold to {installed}")
840
+ print(
841
+ f"auto_sync: scaffold upgraded to {installed}; committed "
842
+ f"{len(dirty)} scaffold file(s) on '{branch}'.",
843
+ file=sys.stderr,
844
+ )
845
+
846
+
769
847
  def _branch_prep_hint(feature_dir: "Path", feat_fm: dict, feature_id: str) -> str:
770
848
  """Actionable next-step block for the two pre-flight guards.
771
849
 
@@ -920,6 +998,11 @@ def prepare_feature(feat_fm: dict, feature_dir: "Path", feature_id: str) -> None
920
998
  for rel in (".specfuse/roadmap.md", ".specfuse/roadmap-archive.md"):
921
999
  if (repo_root / rel).exists():
922
1000
  add_paths.append(str(repo_root / rel))
1001
+ # Fold auto_sync's scaffold overlay into this commit too. On the default
1002
+ # branch auto_sync leaves those uncommitted (see _persist_scaffold_sync);
1003
+ # committing them here lands the upgrade on the feature branch, clean.
1004
+ for rel in sorted(_scaffold_managed_dirty()):
1005
+ add_paths.append(str(repo_root / rel))
923
1006
  git("add", "--", *add_paths)
924
1007
  staged = git("status", "--porcelain", "--", *add_paths)
925
1008
  if staged:
@@ -1006,7 +1089,11 @@ def ensure_feature_branch(feat_fm: dict, feature_dir: "Path | None" = None) -> N
1006
1089
  # Create-from-HEAD carries the working tree onto the new branch. Only
1007
1090
  # the expected /pick-feature flips may ride along; anything else stops.
1008
1091
  dirty = _tracked_dirty_paths()
1009
- unexpected = dirty - _expected_flip_paths(feature_dir)
1092
+ # Scaffold-overlay files (auto_sync's own upgrade writes) ride along too:
1093
+ # they are driver-owned, and prepare_feature folds them into the scaffold
1094
+ # commit. Only genuinely-unrelated edits remain "unexpected" (#prepare).
1095
+ allowed = _expected_flip_paths(feature_dir) | _scaffold_managed_dirty()
1096
+ unexpected = dirty - allowed
1010
1097
  if unexpected:
1011
1098
  raise FeatureBranchError(
1012
1099
  "working tree has uncommitted changes to unexpected paths: "
@@ -3112,7 +3199,14 @@ def run(
3112
3199
  # draft-feature can't take). --prepare-only stops after, so you can
3113
3200
  # review the commit before the loop dispatches anything.
3114
3201
  if prepare or prepare_only:
3115
- prepare_feature(feat_fm, feature_dir, feature_id)
3202
+ # Genuinely-unrelated dirty paths (not the /pick-feature flips, not
3203
+ # auto_sync's scaffold overlay) still stop --prepare — but with a
3204
+ # clean, actionable message and a non-zero exit, not a traceback.
3205
+ try:
3206
+ prepare_feature(feat_fm, feature_dir, feature_id)
3207
+ except FeatureBranchError as exc:
3208
+ print(f"--prepare cannot proceed:\n{exc}", file=sys.stderr)
3209
+ return 1
3116
3210
  if prepare_only:
3117
3211
  print("Prepared: feature is on its branch and committed. "
3118
3212
  "Re-run `specfuse-loop` to start the gate.")
@@ -3998,6 +4092,7 @@ def auto_sync(
3998
4092
  f"WARNING: auto_sync: plugin config drift corrected: {', '.join(changes)}",
3999
4093
  file=sys.stderr,
4000
4094
  )
4095
+ _persist_scaffold_sync(installed)
4001
4096
  return
4002
4097
 
4003
4098
  # Older with modified files.
@@ -4056,6 +4151,7 @@ def auto_sync(
4056
4151
  f"WARNING: auto_sync: plugin config drift corrected: {', '.join(changes)}",
4057
4152
  file=sys.stderr,
4058
4153
  )
4154
+ _persist_scaffold_sync(installed)
4059
4155
  else:
4060
4156
  # Non-interactive (CI / claude -p): skip modified files + warn; never block.
4061
4157
  print(
@@ -4092,6 +4188,7 @@ def auto_sync(
4092
4188
  f"WARNING: auto_sync: plugin config drift corrected: {', '.join(changes)}",
4093
4189
  file=sys.stderr,
4094
4190
  )
4191
+ _persist_scaffold_sync(installed)
4095
4192
 
4096
4193
 
4097
4194
  def main() -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.11
3
+ Version: 0.3.12
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
@@ -112,6 +112,7 @@ tests/test_miniyaml_equivalence.py
112
112
  tests/test_miniyaml_negative.py
113
113
  tests/test_planned_cost_lint.py
114
114
  tests/test_prepare_feature.py
115
+ tests/test_prepare_scaffold_sync.py
115
116
  tests/test_produces_field.py
116
117
  tests/test_result_block.py
117
118
  tests/test_roadmap_add_skill.py
@@ -0,0 +1,172 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """auto_sync's scaffold upgrade must not poison the same run's --prepare.
6
+
7
+ Before this fix, `specfuse-loop --prepare` crashed with a traceback: auto_sync
8
+ overlays the new scaffold (dirtying .specfuse/VERSION, .scaffold-manifest,
9
+ docs/, templates/) and leaves it uncommitted, then ensure_feature_branch
10
+ refuses to carry those "unexpected" paths onto the new feature branch.
11
+
12
+ The graceful behavior pinned here:
13
+
14
+ 1. auto_sync COMMITS its own scaffold overlay on a non-default branch
15
+ (`chore(loop): sync scaffold to X.Y.Z`) so the tree is clean; on the
16
+ DEFAULT branch it leaves them + prints guidance (--prepare carries them).
17
+ 2. ensure_feature_branch CARRIES scaffold-managed dirty paths onto a new
18
+ branch instead of refusing — but still blocks genuinely-unrelated edits.
19
+ 3. prepare_feature FOLDS the scaffold-managed paths into its scaffold commit
20
+ (so the default-branch case lands them committed on the feature branch).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import subprocess
27
+ import unittest
28
+ from contextlib import contextmanager
29
+ from pathlib import Path
30
+ from tempfile import TemporaryDirectory
31
+
32
+ from tests._loop_loader import load_loop
33
+
34
+ loop = load_loop()
35
+
36
+
37
+ def _git(root: Path, *args: str) -> str:
38
+ return subprocess.run(
39
+ ["git", "-C", str(root), *args],
40
+ capture_output=True, text=True, check=True,
41
+ ).stdout.strip()
42
+
43
+
44
+ @contextmanager
45
+ def _chdir(path: Path):
46
+ prev = Path.cwd()
47
+ os.chdir(path)
48
+ try:
49
+ yield
50
+ finally:
51
+ os.chdir(prev)
52
+
53
+
54
+ @contextmanager
55
+ def _repo():
56
+ """Temp git repo on `main`, one commit, a tracked scaffold template."""
57
+ with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
58
+ root = Path(tmp)
59
+ subprocess.run(["git", "init", "-q", "-b", "main", str(root)], check=True)
60
+ _git(root, "config", "user.email", "test@example.com")
61
+ _git(root, "config", "user.name", "Test")
62
+ _git(root, "config", "commit.gpgSign", "false")
63
+ tmpl = root / ".specfuse/templates/WU.template.md"
64
+ tmpl.parent.mkdir(parents=True)
65
+ tmpl.write_text("old template\n")
66
+ (root / ".specfuse/VERSION").write_text("0.3.0\n")
67
+ (root / "README.md").write_text("# fixture\n")
68
+ _git(root, "add", ".")
69
+ _git(root, "commit", "-q", "-m", "init")
70
+ yield root
71
+
72
+
73
+ def _dirty_scaffold(root: Path) -> None:
74
+ """Simulate auto_sync's overlay: modify tracked scaffold files."""
75
+ (root / ".specfuse/templates/WU.template.md").write_text("new template\n")
76
+ (root / ".specfuse/VERSION").write_text("0.3.11\n")
77
+
78
+
79
+ class TestDefaultBranch(unittest.TestCase):
80
+ def test_local_main_detected(self):
81
+ with _repo() as root, _chdir(root):
82
+ self.assertEqual(loop._default_branch(), "main")
83
+
84
+
85
+ class TestScaffoldManagedDirty(unittest.TestCase):
86
+ def test_classifies_scaffold_vs_user_paths(self):
87
+ with _repo() as root:
88
+ _dirty_scaffold(root)
89
+ (root / "README.md").write_text("# edited\n") # user edit
90
+ with _chdir(root):
91
+ managed = loop._scaffold_managed_dirty()
92
+ self.assertIn(".specfuse/templates/WU.template.md", managed)
93
+ self.assertIn(".specfuse/VERSION", managed)
94
+ self.assertNotIn("README.md", managed)
95
+
96
+
97
+ class TestPersistScaffoldSync(unittest.TestCase):
98
+ def test_commits_on_feature_branch(self):
99
+ with _repo() as root:
100
+ _git(root, "checkout", "-q", "-b", "feat/x")
101
+ _dirty_scaffold(root)
102
+ with _chdir(root):
103
+ loop._persist_scaffold_sync("0.3.11")
104
+ dirty = loop._tracked_dirty_paths()
105
+ self.assertEqual(dirty, set(), "scaffold sync must be committed")
106
+ self.assertIn("sync scaffold to 0.3.11",
107
+ _git(root, "log", "-1", "--pretty=%s"))
108
+
109
+ def test_guides_and_leaves_on_default_branch(self):
110
+ with _repo() as root: # on main == default
111
+ _dirty_scaffold(root)
112
+ with _chdir(root):
113
+ loop._persist_scaffold_sync("0.3.11")
114
+ dirty = loop._tracked_dirty_paths()
115
+ # left uncommitted (no surprise commit on the default branch)
116
+ self.assertIn(".specfuse/VERSION", dirty)
117
+ self.assertEqual("init", _git(root, "log", "-1", "--pretty=%s"))
118
+
119
+
120
+ class TestEnsureFeatureBranchCarriesScaffold(unittest.TestCase):
121
+ def test_scaffold_paths_carry_onto_new_branch(self):
122
+ with _repo() as root:
123
+ _dirty_scaffold(root)
124
+ with _chdir(root):
125
+ loop.ensure_feature_branch({"branch": "feat/y"})
126
+ current = _git(root, "branch", "--show-current")
127
+ self.assertEqual(current, "feat/y")
128
+ self.assertEqual(
129
+ (root / ".specfuse/templates/WU.template.md").read_text(),
130
+ "new template\n",
131
+ )
132
+
133
+ def test_unexpected_edit_still_blocks_even_with_scaffold_dirty(self):
134
+ with _repo() as root:
135
+ _commit = root / "src/app.py"
136
+ _commit.parent.mkdir()
137
+ _commit.write_text("x = 1\n")
138
+ _git(root, "add", ".")
139
+ _git(root, "commit", "-q", "-m", "app")
140
+ _dirty_scaffold(root)
141
+ (root / "src/app.py").write_text("x = 2\n") # unrelated edit
142
+ with _chdir(root):
143
+ with self.assertRaises(loop.FeatureBranchError) as ctx:
144
+ loop.ensure_feature_branch({"branch": "feat/z"})
145
+ self.assertIn("src/app.py", str(ctx.exception))
146
+
147
+
148
+ class TestPrepareFoldsScaffold(unittest.TestCase):
149
+ def test_default_branch_scaffold_folds_into_prepare_commit(self):
150
+ with _repo() as root: # on main == default
151
+ feat_dir = root / ".specfuse/features/FEAT-2026-0099-x"
152
+ feat_dir.mkdir(parents=True)
153
+ (feat_dir / "PLAN.md").write_text("branch: feat/FEAT-2026-0099-x\n")
154
+ _dirty_scaffold(root) # auto_sync left these on default branch
155
+ with _chdir(root):
156
+ loop.prepare_feature(
157
+ {"branch": "feat/FEAT-2026-0099-x"}, feat_dir,
158
+ "FEAT-2026-0099",
159
+ )
160
+ dirty = loop._tracked_dirty_paths()
161
+ current = _git(root, "branch", "--show-current")
162
+ self.assertEqual(current, "feat/FEAT-2026-0099-x")
163
+ self.assertEqual(dirty, set(), "scaffold + folder must be committed")
164
+ # scaffold file is committed on the feature branch
165
+ self.assertEqual(
166
+ _git(root, "show", "HEAD:.specfuse/templates/WU.template.md"),
167
+ "new template",
168
+ )
169
+
170
+
171
+ if __name__ == "__main__":
172
+ unittest.main()
@@ -1 +0,0 @@
1
- 0.3.11
File without changes
File without changes
File without changes
File without changes