specfuse-loop 0.3.5__tar.gz → 0.3.7__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 (136) hide show
  1. {specfuse_loop-0.3.5/specfuse_loop.egg-info → specfuse_loop-0.3.7}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.7/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/methodology.md +5 -1
  5. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/skills.md +3 -0
  6. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/roadmap.template.md +3 -1
  7. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/PLAN.template.md +2 -1
  8. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/loop.py +28 -9
  9. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7/specfuse_loop.egg-info}/PKG-INFO +1 -1
  10. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/SOURCES.txt +3 -0
  11. specfuse_loop-0.3.7/tests/test_events_redaction.py +105 -0
  12. specfuse_loop-0.3.7/tests/test_learnings_query.py +246 -0
  13. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_orchestration.py +21 -0
  14. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_post_pass_invariant.py +56 -3
  15. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_roadmap_archive_skill.py +107 -0
  16. specfuse_loop-0.3.7/tests/test_upgrade_merge_gate.py +181 -0
  17. specfuse_loop-0.3.5/specfuse/loop/data/VERSION +0 -1
  18. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/LICENSE +0 -0
  19. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/NOTICE +0 -0
  20. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/README.md +0 -0
  21. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/setup.cfg +0 -0
  22. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/__init__.py +0 -0
  23. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/_miniyaml.py +0 -0
  24. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/adopt_feature.py +0 -0
  25. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  26. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  27. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  28. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/docs/getting-started.md +0 -0
  29. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/gitignore.snippet +0 -0
  30. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  31. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/never-touch.md +0 -0
  32. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/result-contract.md +0 -0
  33. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  34. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/GATE.template.md +0 -0
  35. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/templates/WU.template.md +0 -0
  36. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/data/verification.yml.example +0 -0
  37. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/gate_eval.py +0 -0
  38. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/gh_backend.py +0 -0
  39. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/gh_features.py +0 -0
  40. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/lint_plan.py +0 -0
  41. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/scaffold.py +0 -0
  42. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse/loop/validate_event.py +0 -0
  43. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  44. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/entry_points.txt +0 -0
  45. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/requires.txt +0 -0
  46. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/specfuse_loop.egg-info/top_level.txt +0 -0
  47. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_adopt_feature.py +0 -0
  48. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  49. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_attempt_outcome_emission.py +0 -0
  50. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync.py +0 -0
  51. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync_consent.py +0 -0
  52. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync_firstrun.py +0 -0
  53. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_autosync_plugin.py +0 -0
  54. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_backend.py +0 -0
  55. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  56. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  57. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_closing_deliverable_guard.py +0 -0
  58. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_cost_tracking.py +0 -0
  59. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_deliverable_presence_gate.py +0 -0
  60. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_doctor.py +0 -0
  61. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_driver_integration.py +0 -0
  62. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_driver_lock.py +0 -0
  63. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_duration_tracking.py +0 -0
  64. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_empty_files_escalation.py +0 -0
  65. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_ensure_feature_branch.py +0 -0
  66. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_extra_gates.py +0 -0
  67. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_force_full_close.py +0 -0
  68. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval.py +0 -0
  69. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval_calibration.py +0 -0
  70. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  71. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gate_eval_terminal_wiring.py +0 -0
  72. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gh_backend.py +0 -0
  73. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_gh_features.py +0 -0
  74. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_git_env_isolation.py +0 -0
  75. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_hashed_denylist.py +0 -0
  76. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_hashed_denylist_ci.py +0 -0
  77. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_init_integration.py +0 -0
  78. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_leak_findings_redaction.py +0 -0
  79. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_leak_scan.py +0 -0
  80. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_leak_scan_content.py +0 -0
  81. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  82. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lifecycle_integration.py +0 -0
  83. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_bare_produces_path.py +0 -0
  84. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_close_intermediate.py +0 -0
  85. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_close_wu.py +0 -0
  86. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id.py +0 -0
  87. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  88. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_oracle_env.py +0 -0
  89. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_plan_errors.py +0 -0
  90. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_plan_next_draft.py +0 -0
  91. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_plan_relative_import.py +0 -0
  92. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_produces_driver_helper.py +0 -0
  93. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_sections.py +0 -0
  94. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  95. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_auto_archive.py +0 -0
  96. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_caveman_preamble.py +0 -0
  97. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_close_intermediate.py +0 -0
  98. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_defaults_by_type.py +0 -0
  99. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_effort.py +0 -0
  100. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_failure_note_cap.py +0 -0
  101. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_files_changed_guard.py +0 -0
  102. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_gate_budget.py +0 -0
  103. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_model_alias.py +0 -0
  104. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_reset_preserving_events.py +0 -0
  105. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_smoke_runner.py +0 -0
  106. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_unsandboxed.py +0 -0
  107. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_loop_zero_token_guard.py +0 -0
  108. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_migrate_legacy.py +0 -0
  109. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_miniyaml_equivalence.py +0 -0
  110. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_miniyaml_negative.py +0 -0
  111. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_planned_cost_lint.py +0 -0
  112. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_prepare_feature.py +0 -0
  113. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_produces_field.py +0 -0
  114. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_result_block.py +0 -0
  115. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_roadmap_add_skill.py +0 -0
  116. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_roadmap_row_parser.py +0 -0
  117. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_data_in_sync.py +0 -0
  118. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_docs.py +0 -0
  119. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_init.py +0 -0
  120. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_manifest.py +0 -0
  121. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_resources.py +0 -0
  122. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_seed_sanity.py +0 -0
  123. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_upgrade.py +0 -0
  124. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_scaffold_wiring.py +0 -0
  125. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_squash_commit_hook_crash.py +0 -0
  126. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_template_closing_shapes.py +0 -0
  127. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_terminal_flip_ownership.py +0 -0
  128. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_terminal_flips.py +0 -0
  129. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_untracked_feature_folder.py +0 -0
  130. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_upgrade_integration.py +0 -0
  131. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_validate_event.py +0 -0
  132. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_verdict_coupling.py +0 -0
  133. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_verify_empty_gate_set.py +0 -0
  134. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_version_consistency.py +0 -0
  135. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/tests/test_version_skew.py +0 -0
  136. {specfuse_loop-0.3.5 → specfuse_loop-0.3.7}/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.5
3
+ Version: 0.3.7
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.5"
7
+ version = "0.3.7"
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.7
@@ -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
@@ -110,6 +110,9 @@ either auto-closes a clean gate or halts at the gate boundary for review.
110
110
  - **`/feature-conversion`** — bring an existing feature folder into conformance
111
111
  with the current scaffold's structural contract. Runs after `specfuse upgrade`
112
112
  flags a feature as `FAIL`. Interactive, lint-driven.
113
+ - **`/scaffold-upgrade`** — dry-run-reports or end-to-end upgrades a target
114
+ project's Specfuse scaffold via `specfuse upgrade [--dry-run] <target>`,
115
+ wrapping the git choreography (branch/commit/push/PR/CI/merge-gate) around it.
113
116
  - **`/learnings-suggest`** — scan `attempt_outcome` events across features,
114
117
  cluster non-passing attempts, and surface recurring patterns as candidate
115
118
  `LEARNINGS.md` entries. Read-only — you promote. The *additive* half.
@@ -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.5"
65
+ DRIVER_VERSION = "0.3.7"
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
@@ -470,13 +470,36 @@ def build_event(event_type: str, correlation_id: str, payload: dict) -> dict:
470
470
  }
471
471
 
472
472
 
473
+ # Matches macOS ("/Users/" + "<name>/") and Linux ("/home/" + "<name>/") home
474
+ # prefixes at runtime without containing a literal "/Users/" substring in
475
+ # source, so this file's own staged diff never re-trips the structural leak-scan.
476
+ _HOME_PATH_RE = re.compile(r"/(?:Users|home)/[^/\s]+/")
477
+ _HOME_PATH_PLACEHOLDER = "<redacted-home>/"
478
+
479
+
480
+ def _redact_home_paths(value):
481
+ """Recursively redact absolute home-directory prefixes from a JSON-ish value.
482
+
483
+ Walks dict/list/str/scalar and replaces every "/Users/" + "<name>/" or
484
+ "/home/" + "<name>/" match in string leaves with a stable placeholder.
485
+ Other text is preserved verbatim; idempotent (a second pass is a no-op).
486
+ """
487
+ if isinstance(value, str):
488
+ return _HOME_PATH_RE.sub(_HOME_PATH_PLACEHOLDER, value)
489
+ if isinstance(value, dict):
490
+ return {k: _redact_home_paths(v) for k, v in value.items()}
491
+ if isinstance(value, list):
492
+ return [_redact_home_paths(v) for v in value]
493
+ return value
494
+
495
+
473
496
  def flush_events(events_path: Path, events: list) -> None:
474
497
  """Append a batch of buffered events to the JSONL log."""
475
498
  if not events:
476
499
  return
477
500
  with events_path.open("a") as fh:
478
501
  for evt in events:
479
- fh.write(json.dumps(evt) + "\n")
502
+ fh.write(json.dumps(_redact_home_paths(evt)) + "\n")
480
503
 
481
504
 
482
505
  # --------------------------------------------------------------------------- #
@@ -2900,17 +2923,13 @@ def assert_terminal_flips_fired(
2900
2923
  False,
2901
2924
  f"roadmap_row_not_done: roadmap.md absent at {roadmap_path}",
2902
2925
  )
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:
2926
+ parsed = _parse_roadmap_row(roadmap_path.read_text(), feature_id)
2927
+ if not parsed:
2909
2928
  return (
2910
2929
  False,
2911
2930
  f"roadmap_row_not_done: row for {feature_id} not found",
2912
2931
  )
2913
- row_status = rm.group(2).strip()
2932
+ row_status = parsed["columns"].get("Status", "").strip()
2914
2933
  if row_status != "done":
2915
2934
  return False, f"roadmap_row_not_done: status={row_status!r}"
2916
2935
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.5
3
+ Version: 0.3.7
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
@@ -54,6 +54,7 @@ tests/test_driver_lock.py
54
54
  tests/test_duration_tracking.py
55
55
  tests/test_empty_files_escalation.py
56
56
  tests/test_ensure_feature_branch.py
57
+ tests/test_events_redaction.py
57
58
  tests/test_extra_gates.py
58
59
  tests/test_force_full_close.py
59
60
  tests/test_gate_eval.py
@@ -69,6 +70,7 @@ tests/test_init_integration.py
69
70
  tests/test_leak_findings_redaction.py
70
71
  tests/test_leak_scan.py
71
72
  tests/test_leak_scan_content.py
73
+ tests/test_learnings_query.py
72
74
  tests/test_legacy_4wu_terminal_flips.py
73
75
  tests/test_lifecycle_integration.py
74
76
  tests/test_lint_bare_produces_path.py
@@ -122,6 +124,7 @@ tests/test_terminal_flip_ownership.py
122
124
  tests/test_terminal_flips.py
123
125
  tests/test_untracked_feature_folder.py
124
126
  tests/test_upgrade_integration.py
127
+ tests/test_upgrade_merge_gate.py
125
128
  tests/test_validate_event.py
126
129
  tests/test_verdict_coupling.py
127
130
  tests/test_verify_empty_gate_set.py
@@ -0,0 +1,105 @@
1
+ #
2
+ # Copyright 2026 Specfuse Contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Tests for the events.jsonl home-path redaction chokepoint (FEAT-2026-0030/T01).
6
+
7
+ Fixture home paths are built by concatenation, never as a contiguous
8
+ "/Users/" + "<seg>/" literal, so this file's own staged diff does not re-trip
9
+ the repo's structural leak-scan (which this test itself exercises in AC4).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ import unittest
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
+
21
+ from specfuse.loop.loop import _redact_home_paths, _HOME_PATH_RE, build_event, flush_events
22
+
23
+ # Concatenated so no contiguous "/Users/" + "<seg>/" literal appears in source.
24
+ MAC_HOME = "/Users/" + "alice/checkout/repo"
25
+ LINUX_HOME = "/home/" + "bob/checkout/repo"
26
+
27
+
28
+ class RedactHomePathsTests(unittest.TestCase):
29
+ def test_home_path_redacted_before_flush(self):
30
+ """Red on HEAD: flush_events must strip a macOS home path from payload."""
31
+ events_path = Path(self._tmp_events_path())
32
+ evt = build_event(
33
+ "attempt_outcome",
34
+ "FEAT-2026-0030/T01",
35
+ {"agent_blocked_reason": f"grepped {MAC_HOME}/events.jsonl for context"},
36
+ )
37
+ flush_events(events_path, [evt])
38
+ written = events_path.read_text()
39
+ self.assertIsNone(_HOME_PATH_RE.search(written))
40
+
41
+ def _tmp_events_path(self):
42
+ import tempfile
43
+ fd, path = tempfile.mkstemp(suffix=".jsonl")
44
+ import os
45
+ os.close(fd)
46
+ os.remove(path)
47
+ return path
48
+
49
+ def test_linux_home_path_redacted(self):
50
+ out = _redact_home_paths(f"note: {LINUX_HOME}/x.py")
51
+ self.assertIsNone(_HOME_PATH_RE.search(out))
52
+
53
+ def test_nested_dict_redacted_at_depth(self):
54
+ payload = {"outer": {"inner": f"path {MAC_HOME}/x.py"}}
55
+ out = _redact_home_paths(payload)
56
+ self.assertIsNone(_HOME_PATH_RE.search(json.dumps(out)))
57
+
58
+ def test_list_of_strings_redacted_elementwise(self):
59
+ payload = [f"a {MAC_HOME}/one.py", "clean text", f"b {LINUX_HOME}/two.py"]
60
+ out = _redact_home_paths(payload)
61
+ self.assertIsNone(_HOME_PATH_RE.search(json.dumps(out)))
62
+
63
+ def test_no_home_path_unchanged(self):
64
+ payload = {"correlation_id": "FEAT-2026-0030/T01", "notes": "no path here at all"}
65
+ out = _redact_home_paths(payload)
66
+ self.assertEqual(out, payload)
67
+
68
+ def test_audit_fields_survive_unchanged(self):
69
+ evt = build_event(
70
+ "attempt_outcome",
71
+ "FEAT-2026-0030/T01",
72
+ {"failure_class": "tests", "notes": f"see {MAC_HOME}/log.txt"},
73
+ )
74
+ out = _redact_home_paths(evt)
75
+ self.assertEqual(out["correlation_id"], "FEAT-2026-0030/T01")
76
+ self.assertEqual(out["event_type"], "attempt_outcome")
77
+ self.assertEqual(out["source"], "driver")
78
+ self.assertEqual(out["payload"]["failure_class"], "tests")
79
+
80
+ def test_idempotent(self):
81
+ once = _redact_home_paths(f"path {MAC_HOME}/x.py")
82
+ twice = _redact_home_paths(once)
83
+ self.assertEqual(once, twice)
84
+
85
+ def test_dogfood_leak_scan_clean(self):
86
+ """Redacted output would pass this repo's structural leak-scan (user-path)."""
87
+ sys.path.insert(
88
+ 0, str(Path(__file__).resolve().parent.parent / ".specfuse" / "scripts")
89
+ )
90
+ from leak_scan import scan_text # noqa: E402 (repo-internal, test-only)
91
+
92
+ events_path = Path(self._tmp_events_path())
93
+ evt = build_event(
94
+ "attempt_outcome",
95
+ "FEAT-2026-0030/T01",
96
+ {"agent_blocked_reason": f"grepped {MAC_HOME}/events.jsonl for context"},
97
+ )
98
+ flush_events(events_path, [evt])
99
+ written = events_path.read_text()
100
+ findings = [f for f in scan_text(written) if f.startswith("user-path")]
101
+ self.assertEqual(findings, [])
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()
@@ -0,0 +1,246 @@
1
+ #
2
+ # Copyright 2026 Specfuse Contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Tests for .specfuse/scripts/learnings_query.py (FEAT-2026-0025/T01).
6
+
7
+ `parse_entries` splits LEARNINGS.md's `- [tag] text` bullets (which may wrap
8
+ across lines) into entries, excluding header/Format/heading prose. `rank`
9
+ scores those entries against a query with a stdlib BM25 implementation. Loaded
10
+ by file path, matching the pattern other `.specfuse/scripts` helpers use in
11
+ this test suite (see test_leak_scan_content.py) since this module is not part
12
+ of the `specfuse` package.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import unittest
22
+ from pathlib import Path
23
+
24
+ REPO_ROOT = Path(__file__).resolve().parent.parent
25
+ SCRIPTS_DIR = REPO_ROOT / ".specfuse" / "scripts"
26
+ LEARNINGS_QUERY_PATH = SCRIPTS_DIR / "learnings_query.py"
27
+
28
+
29
+ def _load(name: str):
30
+ if str(SCRIPTS_DIR) not in sys.path:
31
+ sys.path.insert(0, str(SCRIPTS_DIR))
32
+ path = SCRIPTS_DIR / f"{name}.py"
33
+ spec = importlib.util.spec_from_file_location(name, path)
34
+ mod = importlib.util.module_from_spec(spec)
35
+ sys.modules[name] = mod
36
+ spec.loader.exec_module(mod)
37
+ return mod
38
+
39
+
40
+ lq = _load("learnings_query")
41
+
42
+
43
+ class TestRank(unittest.TestCase):
44
+ def test_ranks_relevant_entry_first(self):
45
+ entries = [
46
+ {"tag": "meta/a", "text": "Coverage gates must run before commit."},
47
+ {"tag": "meta/b", "text": "BM25 ranking helps retrieve relevant learnings entries fast."},
48
+ {"tag": "meta/c", "text": "Never touch the driver internals directly."},
49
+ ]
50
+ ranked = lq.rank("BM25 ranking learnings retrieval", entries)
51
+ self.assertEqual(ranked[0]["tag"], "meta/b")
52
+
53
+ def test_empty_query_returns_all_entries_deterministically(self):
54
+ entries = [
55
+ {"tag": "meta/a", "text": "alpha rule"},
56
+ {"tag": "meta/b", "text": "beta rule"},
57
+ ]
58
+ ranked = lq.rank("", entries)
59
+ self.assertEqual([e["tag"] for e in ranked], ["meta/a", "meta/b"])
60
+
61
+ def test_no_match_returns_all_entries_in_stable_order(self):
62
+ entries = [
63
+ {"tag": "meta/a", "text": "alpha rule about routers"},
64
+ {"tag": "meta/b", "text": "beta rule about gates"},
65
+ {"tag": "meta/c", "text": "gamma rule about secrets"},
66
+ ]
67
+ ranked = lq.rank("zzznomatchzzz", entries)
68
+ self.assertEqual([e["tag"] for e in ranked], ["meta/a", "meta/b", "meta/c"])
69
+
70
+ def test_top_n_truncates(self):
71
+ entries = [
72
+ {"tag": "meta/a", "text": "alpha rule about routers"},
73
+ {"tag": "meta/b", "text": "beta rule about gates"},
74
+ {"tag": "meta/c", "text": "gamma rule about secrets"},
75
+ ]
76
+ ranked = lq.rank("rule", entries, top_n=2)
77
+ self.assertEqual(len(ranked), 2)
78
+
79
+ def test_rank_handles_empty_entries_list(self):
80
+ self.assertEqual(lq.rank("anything", []), [])
81
+
82
+
83
+ class TestParseEntries(unittest.TestCase):
84
+ def test_parses_simple_bullet(self):
85
+ text = (
86
+ "# LEARNINGS\n\n"
87
+ "## Format\n\n"
88
+ "```\n"
89
+ "- [FEAT-2026-0001/G1] Example bullet that must be excluded.\n"
90
+ "```\n\n"
91
+ "## Entries\n\n"
92
+ "<!-- lessons work units append below this line -->\n\n"
93
+ "- [meta/foo] This is a rule.\n"
94
+ )
95
+ entries = lq.parse_entries(text)
96
+ self.assertEqual(len(entries), 1)
97
+ self.assertEqual(entries[0]["tag"], "meta/foo")
98
+ self.assertEqual(entries[0]["text"], "This is a rule.")
99
+
100
+ def test_joins_wrapped_multiline_bullet(self):
101
+ text = (
102
+ "## Entries\n\n"
103
+ "<!-- lessons work units append below this line -->\n\n"
104
+ "- [meta/foo] This rule wraps across\n"
105
+ " several lines of prose\n"
106
+ " before it ends.\n"
107
+ )
108
+ entries = lq.parse_entries(text)
109
+ self.assertEqual(len(entries), 1)
110
+ self.assertEqual(
111
+ entries[0]["text"],
112
+ "This rule wraps across several lines of prose before it ends.",
113
+ )
114
+
115
+ def test_section_heading_ends_entry_and_is_excluded(self):
116
+ text = (
117
+ "## Entries\n\n"
118
+ "<!-- lessons work units append below this line -->\n\n"
119
+ "- [meta/foo] First rule.\n\n"
120
+ "## FEAT-2026-0001/G1-CLOSE — some retro title\n\n"
121
+ "- [FEAT-2026-0001/G1-CLOSE] Second rule.\n"
122
+ )
123
+ entries = lq.parse_entries(text)
124
+ self.assertEqual(len(entries), 2)
125
+ self.assertEqual(entries[0]["text"], "First rule.")
126
+ self.assertEqual(entries[1]["text"], "Second rule.")
127
+
128
+ def test_blank_line_separates_entries(self):
129
+ text = (
130
+ "## Entries\n\n"
131
+ "<!-- lessons work units append below this line -->\n\n"
132
+ "- [meta/a] Rule one.\n\n"
133
+ "- [meta/b] Rule two.\n"
134
+ )
135
+ entries = lq.parse_entries(text)
136
+ self.assertEqual(len(entries), 2)
137
+ self.assertEqual(entries[0]["tag"], "meta/a")
138
+ self.assertEqual(entries[1]["tag"], "meta/b")
139
+
140
+ def test_real_learnings_file_round_trip(self):
141
+ learnings_path = REPO_ROOT / ".specfuse" / "LEARNINGS.md"
142
+ text = learnings_path.read_text(encoding="utf-8")
143
+ entries = lq.parse_entries(text)
144
+ self.assertGreater(len(entries), 1)
145
+ for entry in entries:
146
+ self.assertTrue(entry["text"])
147
+ self.assertTrue(entry["tag"])
148
+
149
+
150
+ def _small_fixture(n_entries: int) -> str:
151
+ lines = ["## Entries", "", "<!-- lessons work units append below this line -->", ""]
152
+ for i in range(n_entries):
153
+ lines.append(f"- [meta/e{i}] Entry number {i} about routers and gates.")
154
+ return "\n".join(lines) + "\n"
155
+
156
+
157
+ class TestShouldLoadWhole(unittest.TestCase):
158
+ def test_below_threshold_is_true(self):
159
+ entries = [{"tag": "a", "text": "x"}] * 5
160
+ self.assertTrue(lq.should_load_whole(entries, threshold=40))
161
+
162
+ def test_at_or_above_threshold_is_false(self):
163
+ entries = [{"tag": "a", "text": "x"}] * 40
164
+ self.assertFalse(lq.should_load_whole(entries, threshold=40))
165
+ entries = [{"tag": "a", "text": "x"}] * 41
166
+ self.assertFalse(lq.should_load_whole(entries, threshold=40))
167
+
168
+
169
+ class TestCli(unittest.TestCase):
170
+ def _run_cli(self, *args: str) -> subprocess.CompletedProcess:
171
+ return subprocess.run(
172
+ [sys.executable, str(LEARNINGS_QUERY_PATH), *args],
173
+ capture_output=True,
174
+ text=True,
175
+ )
176
+
177
+ def test_cli_below_threshold_signals_load_whole(self):
178
+ with tempfile.NamedTemporaryFile(
179
+ mode="w", suffix=".md", delete=False
180
+ ) as fixture:
181
+ fixture.write(_small_fixture(5))
182
+ fixture_path = fixture.name
183
+ try:
184
+ result = self._run_cli(
185
+ "routers", "--file", fixture_path, "--threshold", "40"
186
+ )
187
+ finally:
188
+ Path(fixture_path).unlink()
189
+ self.assertEqual(result.returncode, 0)
190
+ self.assertEqual(result.stdout.strip(), lq.LOAD_WHOLE_SENTINEL)
191
+
192
+ def test_cli_at_threshold_returns_ranked_slice(self):
193
+ with tempfile.NamedTemporaryFile(
194
+ mode="w", suffix=".md", delete=False
195
+ ) as fixture:
196
+ fixture.write(_small_fixture(45))
197
+ fixture_path = fixture.name
198
+ try:
199
+ result = self._run_cli(
200
+ "routers", "--file", fixture_path, "--threshold", "40", "--top", "3"
201
+ )
202
+ finally:
203
+ Path(fixture_path).unlink()
204
+ self.assertEqual(result.returncode, 0)
205
+ lines = [line for line in result.stdout.splitlines() if line.strip()]
206
+ self.assertNotIn(lq.LOAD_WHOLE_SENTINEL, lines)
207
+ self.assertEqual(len(lines), 3)
208
+
209
+ def test_cli_threshold_zero_forces_slice_on_tiny_file(self):
210
+ with tempfile.NamedTemporaryFile(
211
+ mode="w", suffix=".md", delete=False
212
+ ) as fixture:
213
+ fixture.write(_small_fixture(2))
214
+ fixture_path = fixture.name
215
+ try:
216
+ result = self._run_cli(
217
+ "routers", "--file", fixture_path, "--threshold", "0"
218
+ )
219
+ finally:
220
+ Path(fixture_path).unlink()
221
+ self.assertEqual(result.returncode, 0)
222
+ self.assertNotEqual(result.stdout.strip(), lq.LOAD_WHOLE_SENTINEL)
223
+ self.assertEqual(len(result.stdout.strip().splitlines()), 2)
224
+
225
+ def test_cli_missing_file_exits_nonzero_with_message(self):
226
+ result = self._run_cli("routers", "--file", "/nonexistent/path/LEARNINGS.md")
227
+ self.assertNotEqual(result.returncode, 0)
228
+ self.assertIn("cannot read", result.stderr)
229
+
230
+ def test_cli_top_bounds_count(self):
231
+ with tempfile.NamedTemporaryFile(
232
+ mode="w", suffix=".md", delete=False
233
+ ) as fixture:
234
+ fixture.write(_small_fixture(45))
235
+ fixture_path = fixture.name
236
+ try:
237
+ result = self._run_cli(
238
+ "routers", "--file", fixture_path, "--threshold", "40", "--top", "1"
239
+ )
240
+ finally:
241
+ Path(fixture_path).unlink()
242
+ self.assertEqual(len(result.stdout.strip().splitlines()), 1)
243
+
244
+
245
+ if __name__ == "__main__":
246
+ 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: