specfuse-loop 0.3.4__tar.gz → 0.3.6__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 (133) hide show
  1. {specfuse_loop-0.3.4/specfuse_loop.egg-info → specfuse_loop-0.3.6}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.6/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/methodology.md +5 -1
  5. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/roadmap.template.md +3 -1
  6. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/templates/PLAN.template.md +2 -1
  7. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/loop.py +10 -9
  8. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6/specfuse_loop.egg-info}/PKG-INFO +1 -1
  9. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  10. specfuse_loop-0.3.6/tests/test_lint_plan_relative_import.py +57 -0
  11. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_orchestration.py +21 -0
  12. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_post_pass_invariant.py +56 -3
  13. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_roadmap_archive_skill.py +107 -0
  14. specfuse_loop-0.3.4/specfuse/loop/data/VERSION +0 -1
  15. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/LICENSE +0 -0
  16. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/NOTICE +0 -0
  17. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/README.md +0 -0
  18. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/setup.cfg +0 -0
  19. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/__init__.py +0 -0
  20. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/_miniyaml.py +0 -0
  21. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/adopt_feature.py +0 -0
  22. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  23. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  24. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  25. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/getting-started.md +0 -0
  26. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/docs/skills.md +0 -0
  27. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/gitignore.snippet +0 -0
  28. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  29. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/never-touch.md +0 -0
  30. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/result-contract.md +0 -0
  31. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  32. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/templates/GATE.template.md +0 -0
  33. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/templates/WU.template.md +0 -0
  34. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/data/verification.yml.example +0 -0
  35. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/gate_eval.py +0 -0
  36. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/gh_backend.py +0 -0
  37. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/gh_features.py +0 -0
  38. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/lint_plan.py +0 -0
  39. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/scaffold.py +0 -0
  40. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse/loop/validate_event.py +0 -0
  41. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  42. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/entry_points.txt +0 -0
  43. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/requires.txt +0 -0
  44. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/specfuse_loop.egg-info/top_level.txt +0 -0
  45. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_adopt_feature.py +0 -0
  46. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  47. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_attempt_outcome_emission.py +0 -0
  48. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync.py +0 -0
  49. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync_consent.py +0 -0
  50. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync_firstrun.py +0 -0
  51. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_autosync_plugin.py +0 -0
  52. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_backend.py +0 -0
  53. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  54. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  55. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_closing_deliverable_guard.py +0 -0
  56. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_cost_tracking.py +0 -0
  57. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_deliverable_presence_gate.py +0 -0
  58. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_doctor.py +0 -0
  59. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_driver_integration.py +0 -0
  60. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_driver_lock.py +0 -0
  61. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_duration_tracking.py +0 -0
  62. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_empty_files_escalation.py +0 -0
  63. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_ensure_feature_branch.py +0 -0
  64. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_extra_gates.py +0 -0
  65. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_force_full_close.py +0 -0
  66. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval.py +0 -0
  67. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval_calibration.py +0 -0
  68. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  69. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gate_eval_terminal_wiring.py +0 -0
  70. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gh_backend.py +0 -0
  71. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_gh_features.py +0 -0
  72. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_git_env_isolation.py +0 -0
  73. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_hashed_denylist.py +0 -0
  74. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_hashed_denylist_ci.py +0 -0
  75. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_init_integration.py +0 -0
  76. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_leak_findings_redaction.py +0 -0
  77. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_leak_scan.py +0 -0
  78. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_leak_scan_content.py +0 -0
  79. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  80. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lifecycle_integration.py +0 -0
  81. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_bare_produces_path.py +0 -0
  82. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_close_intermediate.py +0 -0
  83. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_close_wu.py +0 -0
  84. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_correlation_id.py +0 -0
  85. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  86. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_oracle_env.py +0 -0
  87. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_plan_errors.py +0 -0
  88. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_plan_next_draft.py +0 -0
  89. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_produces_driver_helper.py +0 -0
  90. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_sections.py +0 -0
  91. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  92. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_auto_archive.py +0 -0
  93. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_caveman_preamble.py +0 -0
  94. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_close_intermediate.py +0 -0
  95. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_defaults_by_type.py +0 -0
  96. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_effort.py +0 -0
  97. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_failure_note_cap.py +0 -0
  98. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_files_changed_guard.py +0 -0
  99. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_gate_budget.py +0 -0
  100. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_model_alias.py +0 -0
  101. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_reset_preserving_events.py +0 -0
  102. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_smoke_runner.py +0 -0
  103. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_unsandboxed.py +0 -0
  104. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_loop_zero_token_guard.py +0 -0
  105. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_migrate_legacy.py +0 -0
  106. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_miniyaml_equivalence.py +0 -0
  107. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_miniyaml_negative.py +0 -0
  108. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_planned_cost_lint.py +0 -0
  109. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_prepare_feature.py +0 -0
  110. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_produces_field.py +0 -0
  111. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_result_block.py +0 -0
  112. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_roadmap_add_skill.py +0 -0
  113. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_roadmap_row_parser.py +0 -0
  114. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_data_in_sync.py +0 -0
  115. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_docs.py +0 -0
  116. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_init.py +0 -0
  117. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_manifest.py +0 -0
  118. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_resources.py +0 -0
  119. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_seed_sanity.py +0 -0
  120. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_upgrade.py +0 -0
  121. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_scaffold_wiring.py +0 -0
  122. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_squash_commit_hook_crash.py +0 -0
  123. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_template_closing_shapes.py +0 -0
  124. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_terminal_flip_ownership.py +0 -0
  125. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_terminal_flips.py +0 -0
  126. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_untracked_feature_folder.py +0 -0
  127. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_upgrade_integration.py +0 -0
  128. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_validate_event.py +0 -0
  129. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_verdict_coupling.py +0 -0
  130. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_verify_empty_gate_set.py +0 -0
  131. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_version_consistency.py +0 -0
  132. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/tests/test_version_skew.py +0 -0
  133. {specfuse_loop-0.3.4 → specfuse_loop-0.3.6}/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.4
3
+ Version: 0.3.6
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.4"
7
+ version = "0.3.6"
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.6
@@ -18,7 +18,11 @@ on both surfaces.
18
18
  ## 1. The unit hierarchy
19
19
 
20
20
  - **Roadmap** — the master index of features for a repository/project, with each
21
- feature's status (`planned → active → done`/`abandoned`).
21
+ feature's status (`planned → active → done`/`abandoned`). A feature may also be
22
+ `deferred` — parked pending an external decision or dependency: nothing is
23
+ loop-dispatchable, so the driver skips it (like `abandoned`), but it stays
24
+ resumable — a human flips it back to `active` when the blocker clears. `deferred`
25
+ is distinct from `abandoned` (dead) and `done` (complete).
22
26
  - **Feature** — a spec-driven *or directly-authored* unit of value, identified by
23
27
  a correlation ID `FEAT-YYYY-NNNN`. A feature owns an ordered list of gates.
24
28
  - **Gate** — a milestone partition of a feature: an ordered batch of substantive
@@ -22,7 +22,9 @@ the canonical column order below.
22
22
  | Feature ID | Title | Status | Folder | Detail |
23
23
  |----------------|-------|--------|--------|--------|
24
24
 
25
- Status: `planned` → `active` → `done` (or `abandoned`).
25
+ Status: `planned` → `active` → `done` (or `abandoned`). `deferred` = parked
26
+ pending an external decision/dependency; resumable (a human flips it back to
27
+ `active` when the blocker clears), distinct from `abandoned` (dead).
26
28
 
27
29
  ## Notes
28
30
 
@@ -7,7 +7,8 @@ roadmap_goal: <one line copied from the roadmap — the north star this feature
7
7
  plan-next anchors every drafted gate to this and flags if a retrospective implies
8
8
  it should change>
9
9
  autonomy_default: review # auto | review | supervised
10
- status: active # active | done | abandoned
10
+ status: active # active | deferred | done | abandoned
11
+ # deferred = parked pending an external decision/dependency; resumable, non-dispatchable
11
12
  # planned_cost_usd: 0.00 # OPTIONAL — sum of WU planned costs; lint warns when missing or when delta from WU sum > 10%
12
13
  ---
13
14
 
@@ -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.4"
65
+ DRIVER_VERSION = "0.3.6"
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
@@ -2900,17 +2900,13 @@ def assert_terminal_flips_fired(
2900
2900
  False,
2901
2901
  f"roadmap_row_not_done: roadmap.md absent at {roadmap_path}",
2902
2902
  )
2903
- row_re = re.compile(
2904
- r"^\|\s*" + re.escape(feature_id) + r"\s*\|([^|]*)\|([^|]*)\|",
2905
- re.MULTILINE,
2906
- )
2907
- rm = row_re.search(roadmap_path.read_text())
2908
- if not rm:
2903
+ parsed = _parse_roadmap_row(roadmap_path.read_text(), feature_id)
2904
+ if not parsed:
2909
2905
  return (
2910
2906
  False,
2911
2907
  f"roadmap_row_not_done: row for {feature_id} not found",
2912
2908
  )
2913
- row_status = rm.group(2).strip()
2909
+ row_status = parsed["columns"].get("Status", "").strip()
2914
2910
  if row_status != "done":
2915
2911
  return False, f"roadmap_row_not_done: status={row_status!r}"
2916
2912
 
@@ -3531,7 +3527,12 @@ def run(
3531
3527
  # FEAT-2026-0018/T07 — plan-next-draft lint hook (warn-only v1)
3532
3528
  if wu.type == "plan-next":
3533
3529
  try:
3534
- from lint_plan import lint_plan_next_draft
3530
+ # Package-relative + function-local: lint_plan
3531
+ # imports `from .loop import VERDICT_VALUES`, so a
3532
+ # module-top import would be circular; the old
3533
+ # flat (top-level) form broke once the driver
3534
+ # ships as a pip package (#100).
3535
+ from .lint_plan import lint_plan_next_draft
3535
3536
  _warns = lint_plan_next_draft(feature_dir, gate.number)
3536
3537
  except Exception as _exc:
3537
3538
  _warns = [f"lint_plan_next_draft raised: {_exc}"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.4
3
+ Version: 0.3.6
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
@@ -79,6 +79,7 @@ tests/test_lint_correlation_id_close_intermediate.py
79
79
  tests/test_lint_oracle_env.py
80
80
  tests/test_lint_plan_errors.py
81
81
  tests/test_lint_plan_next_draft.py
82
+ tests/test_lint_plan_relative_import.py
82
83
  tests/test_lint_produces_driver_helper.py
83
84
  tests/test_lint_sections.py
84
85
  tests/test_lint_task_graph_yaml_selection.py
@@ -0,0 +1,57 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Regression test for #100 — the plan-next-draft lint hook must use a
6
+ package-relative import.
7
+
8
+ `loop.py`'s plan-next hook imported `from lint_plan import lint_plan_next_draft`
9
+ (flat). That only resolved when the scripts were copied side-by-side (pre-pip);
10
+ in the installed package it raises `No module named 'lint_plan'`, so the
11
+ plan-next gate logs a warning and the draft lint never runs. The import is
12
+ function-local on purpose (lint_plan imports `from .loop import VERDICT_VALUES`,
13
+ so a module-top import would be circular) — the fix is to make the local import
14
+ package-relative, not to hoist it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import unittest
23
+ from pathlib import Path
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parent.parent
26
+ LOOP_SRC = REPO_ROOT / "specfuse" / "loop" / "loop.py"
27
+
28
+
29
+ class TestLintPlanRelativeImport(unittest.TestCase):
30
+
31
+ def test_loop_uses_package_relative_lint_plan_import(self):
32
+ src = LOOP_SRC.read_text(encoding="utf-8")
33
+ self.assertNotIn(
34
+ "from lint_plan import", src,
35
+ "flat `from lint_plan import` breaks in the installed package (#100)",
36
+ )
37
+ self.assertIn(
38
+ "from .lint_plan import lint_plan_next_draft", src,
39
+ "the plan-next hook must import lint_plan_next_draft package-relative",
40
+ )
41
+
42
+ def test_lint_plan_next_draft_importable_outside_repo_root(self):
43
+ """Resolvable as a package member even when CWD is not the repo root
44
+ (so a flat `lint_plan.py` on the path can't mask the bug)."""
45
+ with tempfile.TemporaryDirectory() as tmp:
46
+ r = subprocess.run(
47
+ [sys.executable, "-c",
48
+ "from specfuse.loop.lint_plan import lint_plan_next_draft; "
49
+ "print('ok')"],
50
+ capture_output=True, text=True, cwd=tmp,
51
+ )
52
+ self.assertEqual(r.returncode, 0, r.stderr)
53
+ self.assertIn("ok", r.stdout)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
@@ -319,6 +319,27 @@ class TestFindFeatureSelection(unittest.TestCase):
319
319
  result = loop.find_feature("FEAT-2026-9502-explicit")
320
320
  self.assertEqual(result, fdir)
321
321
 
322
+ def test_deferred_feature_is_not_auto_selected(self):
323
+ """Issue #106: a `deferred` foldered feature is non-dispatchable — the
324
+ driver skips it exactly like a non-active feature (parked, resumable)."""
325
+ self._write_plan("FEAT-2026-9502-deferred", "deferred")
326
+ with self.assertRaises(SystemExit) as ctx:
327
+ loop.find_feature(None)
328
+ self.assertIn("active", str(ctx.exception.code).lower())
329
+
330
+ def test_deferred_does_not_count_as_a_second_active(self):
331
+ """A `deferred` feature alongside one `active` does not trigger the
332
+ multiple-active disambiguation — deferred is not dispatchable."""
333
+ active_dir = self._write_plan("FEAT-2026-9502-live", "active")
334
+ self._write_plan("FEAT-2026-9502-parked", "deferred")
335
+ self.assertEqual(loop.find_feature(None), active_dir)
336
+
337
+ def test_deferred_still_selectable_by_explicit_arg(self):
338
+ """A human can still target a `deferred` feature explicitly (e.g. to
339
+ inspect it); only auto-selection skips it."""
340
+ fdir = self._write_plan("FEAT-2026-9502-parked", "deferred")
341
+ self.assertEqual(loop.find_feature("FEAT-2026-9502-parked"), fdir)
342
+
322
343
  def test_explicit_arg_without_plan_exits(self):
323
344
  """Lines 188-190: explicit dir name with no PLAN.md → sys.exit."""
324
345
  orphan = self._features_dir / "FEAT-2026-9502-orphan"
@@ -69,6 +69,7 @@ def _write_feature_scaffold(
69
69
  roadmap_row_status: str = "done",
70
70
  archive_anchor_present: bool = True,
71
71
  wu_verdict: str = "met",
72
+ roadmap_priority_column: bool = False,
72
73
  ) -> Path:
73
74
  """Write a .specfuse scaffold matching the requested post-flip state.
74
75
 
@@ -98,11 +99,23 @@ def _write_feature_scaffold(
98
99
  f"status: done\nattempts: 1\nverdict: {wu_verdict}\n---\n\n# Close{_WU_BODY}"
99
100
  )
100
101
 
102
+ if roadmap_priority_column:
103
+ # Non-canonical shape: a Priority column sits BEFORE Status, shifting
104
+ # Status from column 3 to column 4. Exercises header-name parsing.
105
+ roadmap_table = (
106
+ f"| Feature ID | Title | Priority | Status | Folder |\n"
107
+ f"|------------|-------|----------|--------|--------|\n"
108
+ f"| {feature_id} | Test feature | P2 | {roadmap_row_status} | — |\n\n"
109
+ )
110
+ else:
111
+ roadmap_table = (
112
+ f"| Feature ID | Title | Status | Folder | Detail |\n"
113
+ f"|------------|-------|--------|--------|--------|\n"
114
+ f"| {feature_id} | Test feature | {roadmap_row_status} | — | — |\n\n"
115
+ )
101
116
  (specfuse / "roadmap.md").write_text(
102
117
  f"---\nproject: test\n---\n\n# Roadmap\n\n"
103
- f"| Feature ID | Title | Status | Folder | Detail |\n"
104
- f"|------------|-------|--------|--------|--------|\n"
105
- f"| {feature_id} | Test feature | {roadmap_row_status} | — | — |\n\n"
118
+ f"{roadmap_table}"
106
119
  f"## {feature_id} — Test feature\n\nContent.\n"
107
120
  )
108
121
 
@@ -175,6 +188,46 @@ class TestAssertTerminalFlipsFired(unittest.TestCase):
175
188
  )
176
189
  self.assertIn("active", reason)
177
190
 
191
+ def test_close_passes_with_priority_column_roadmap(self):
192
+ """Issue #104: roadmap with a Priority column before Status.
193
+
194
+ Positional parsing read the Priority cell ('P2') as the status and
195
+ false-escalated roadmap_row_not_done. Header-name parsing must find
196
+ the real Status column and pass.
197
+ """
198
+ with tempfile.TemporaryDirectory() as tmp:
199
+ root = Path(tmp)
200
+ feature_dir = _write_feature_scaffold(
201
+ root, roadmap_priority_column=True,
202
+ )
203
+ wu = _make_close_wu(feature_dir)
204
+ ok, reason = loop.assert_terminal_flips_fired(
205
+ wu, feature_dir, root, DUMMY_HEAD,
206
+ )
207
+ self.assertTrue(ok, msg=f"unexpected fail: {reason!r}")
208
+ self.assertEqual(reason, "")
209
+
210
+ def test_close_fails_with_priority_column_when_row_active(self):
211
+ """Priority-column table, row genuinely active → still flagged, and the
212
+ reason reports the real Status cell, not the Priority token."""
213
+ with tempfile.TemporaryDirectory() as tmp:
214
+ root = Path(tmp)
215
+ feature_dir = _write_feature_scaffold(
216
+ root, roadmap_priority_column=True,
217
+ roadmap_row_status="active",
218
+ )
219
+ wu = _make_close_wu(feature_dir)
220
+ ok, reason = loop.assert_terminal_flips_fired(
221
+ wu, feature_dir, root, DUMMY_HEAD,
222
+ )
223
+ self.assertFalse(ok)
224
+ self.assertTrue(
225
+ reason.startswith("roadmap_row_not_done:"),
226
+ msg=f"unexpected reason: {reason!r}",
227
+ )
228
+ self.assertIn("active", reason)
229
+ self.assertNotIn("P2", reason)
230
+
178
231
  def test_close_with_verdict_met_fails_when_archive_anchor_absent(self):
179
232
  """Gate + row done but archive missing the lower-cased anchor."""
180
233
  with tempfile.TemporaryDirectory() as tmp:
@@ -104,6 +104,34 @@ def archive_feature(roadmap_path: Path, archive_path: Path, feat_id: str) -> str
104
104
  return "archived"
105
105
 
106
106
 
107
+ def auto_eligible(roadmap_text: str) -> list[str]:
108
+ """Reference impl of `--auto` eligibility, corrected per #102.
109
+
110
+ Eligible = `done`/`abandoned` rows that still have an inline `## FEAT-id — `
111
+ section and are not already back-linked. NOT gated on the `Detail` cell being
112
+ `—`: a folder-path Detail (older convention) is orthogonal to whether the
113
+ inline prose was archived. The buggy rule (`Detail == '—'`) silently skipped
114
+ folder-path rows, leaving their prose inline forever.
115
+ """
116
+ eligible: list[str] = []
117
+ row_re = re.compile(
118
+ r"^\| *(FEAT-\d{4}-\d{4}) *\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|",
119
+ re.MULTILINE,
120
+ )
121
+ for m in row_re.finditer(roadmap_text):
122
+ feat_id = m.group(1).strip()
123
+ status = m.group(3).strip()
124
+ detail = m.group(5).strip()
125
+ if status not in ("done", "abandoned"):
126
+ continue
127
+ if "roadmap-archive.md#" in detail:
128
+ continue # already archived
129
+ if re.search(r"^## " + re.escape(feat_id) + r" — ", roadmap_text,
130
+ re.MULTILINE):
131
+ eligible.append(feat_id)
132
+ return eligible
133
+
134
+
107
135
  # ---------------------------------------------------------------------------
108
136
  # Test fixtures
109
137
  # ---------------------------------------------------------------------------
@@ -240,5 +268,84 @@ class TestArchiveFeature(unittest.TestCase):
240
268
  self.assertEqual(self.archive.read_text(), archive_snapshot)
241
269
 
242
270
 
271
+ _ROADMAP_MIXED = textwrap.dedent("""\
272
+ ---
273
+ project: test
274
+ ---
275
+
276
+ # Roadmap
277
+
278
+ | Feature ID | Title | Status | Folder | Detail |
279
+ |----------------|---------------|-----------|--------|--------|
280
+ | FEAT-2026-0001 | Folder detail | done | f1 | features/FEAT-2026-0001-x/ |
281
+ | FEAT-2026-0002 | Dash detail | done | — | — |
282
+ | FEAT-2026-0003 | Already arch | done | — | [→ archive](roadmap-archive.md#feat-2026-0003) |
283
+ | FEAT-2026-0004 | Planned thing | planned | — | — |
284
+ | FEAT-2026-0005 | Abandoned one | abandoned | f5 | features/FEAT-2026-0005-y/ |
285
+
286
+ ## FEAT-2026-0001 — Folder detail
287
+
288
+ Prose 1.
289
+
290
+ ## FEAT-2026-0002 — Dash detail
291
+
292
+ Prose 2.
293
+
294
+ ## FEAT-2026-0004 — Planned thing
295
+
296
+ Prose 4.
297
+
298
+ ## FEAT-2026-0005 — Abandoned one
299
+
300
+ Prose 5.
301
+
302
+ ## Notes
303
+
304
+ Notes.
305
+ """)
306
+
307
+
308
+ class TestAutoEligibility(unittest.TestCase):
309
+ """#102 — --auto must not gate eligibility on Detail == '—'."""
310
+
311
+ def test_folder_path_detail_done_feature_is_eligible(self):
312
+ elig = auto_eligible(_ROADMAP_MIXED)
313
+ # The #102 regression: folder-path Detail done feature WITH inline prose.
314
+ self.assertIn("FEAT-2026-0001", elig)
315
+ self.assertIn("FEAT-2026-0005", elig) # abandoned + folder-path
316
+
317
+ def test_dash_detail_still_eligible(self):
318
+ self.assertIn("FEAT-2026-0002", auto_eligible(_ROADMAP_MIXED))
319
+
320
+ def test_already_archived_excluded(self):
321
+ self.assertNotIn("FEAT-2026-0003", auto_eligible(_ROADMAP_MIXED))
322
+
323
+ def test_planned_excluded(self):
324
+ self.assertNotIn("FEAT-2026-0004", auto_eligible(_ROADMAP_MIXED))
325
+
326
+ def test_full_eligible_set(self):
327
+ self.assertEqual(
328
+ sorted(auto_eligible(_ROADMAP_MIXED)),
329
+ ["FEAT-2026-0001", "FEAT-2026-0002", "FEAT-2026-0005"],
330
+ )
331
+
332
+ def test_old_dash_only_rule_would_skip_folder_paths(self):
333
+ """Document the bug: gating on Detail == '—' drops the folder-path rows
334
+ that the corrected rule (auto_eligible) includes."""
335
+ dash_only = [f for f in auto_eligible(_ROADMAP_MIXED)
336
+ if _detail_cell(_ROADMAP_MIXED, f) == "—"]
337
+ self.assertEqual(dash_only, ["FEAT-2026-0002"])
338
+ # corrected rule catches 2 more the buggy one missed
339
+ self.assertEqual(len(auto_eligible(_ROADMAP_MIXED)) - len(dash_only), 2)
340
+
341
+
342
+ def _detail_cell(roadmap_text: str, feat_id: str) -> str:
343
+ m = re.search(
344
+ r"^\| *" + re.escape(feat_id) + r" *\|[^|]+\|[^|]+\|[^|]+\|([^|]+)\|",
345
+ roadmap_text, re.MULTILINE,
346
+ )
347
+ return m.group(1).strip() if m else ""
348
+
349
+
243
350
  if __name__ == "__main__":
244
351
  unittest.main()
@@ -1 +0,0 @@
1
- 0.3.4
File without changes
File without changes
File without changes
File without changes