specfuse-loop 0.3.8__tar.gz → 0.3.10__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 (144) hide show
  1. {specfuse_loop-0.3.8/specfuse_loop.egg-info → specfuse_loop-0.3.10}/PKG-INFO +1 -1
  2. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/pyproject.toml +1 -1
  3. specfuse_loop-0.3.10/specfuse/loop/data/VERSION +1 -0
  4. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/loop.py +43 -1
  5. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10/specfuse_loop.egg-info}/PKG-INFO +1 -1
  6. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse_loop.egg-info/SOURCES.txt +1 -0
  7. specfuse_loop-0.3.10/tests/test_degraded_oracle.py +90 -0
  8. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_roadmap_add_skill.py +93 -5
  9. specfuse_loop-0.3.8/specfuse/loop/data/VERSION +0 -1
  10. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/LICENSE +0 -0
  11. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/NOTICE +0 -0
  12. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/README.md +0 -0
  13. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/setup.cfg +0 -0
  14. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/__init__.py +0 -0
  15. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/_miniyaml.py +0 -0
  16. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/adopt_feature.py +0 -0
  17. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/LEARNINGS.template.md +0 -0
  18. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
  19. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
  20. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/docs/getting-started.md +0 -0
  21. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/docs/methodology.md +0 -0
  22. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/docs/skills.md +0 -0
  23. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/gitignore.snippet +0 -0
  24. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/roadmap.template.md +0 -0
  25. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/rules/correlation-ids.md +0 -0
  26. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/rules/never-touch.md +0 -0
  27. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/rules/result-contract.md +0 -0
  28. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/rules/security-boundaries.md +0 -0
  29. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/rules/verification-discipline.md +0 -0
  30. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/schemas/event.schema.json +0 -0
  31. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
  32. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
  33. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
  34. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
  35. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/templates/GATE.template.md +0 -0
  36. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/templates/PLAN.template.md +0 -0
  37. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/templates/WU.template.md +0 -0
  38. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/data/verification.yml.example +0 -0
  39. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/gate_eval.py +0 -0
  40. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/gh_backend.py +0 -0
  41. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/gh_features.py +0 -0
  42. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/lint_plan.py +0 -0
  43. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/scaffold.py +0 -0
  44. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse/loop/validate_event.py +0 -0
  45. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse_loop.egg-info/dependency_links.txt +0 -0
  46. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse_loop.egg-info/entry_points.txt +0 -0
  47. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse_loop.egg-info/requires.txt +0 -0
  48. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/specfuse_loop.egg-info/top_level.txt +0 -0
  49. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_adopt_feature.py +0 -0
  50. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_arm_gate_edits_uncommitted.py +0 -0
  51. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_attempt_outcome_emission.py +0 -0
  52. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_autosync.py +0 -0
  53. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_autosync_consent.py +0 -0
  54. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_autosync_firstrun.py +0 -0
  55. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_autosync_plugin.py +0 -0
  56. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_backend.py +0 -0
  57. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_bookkeeping_commit_crash_run.py +0 -0
  58. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
  59. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_closing_deliverable_guard.py +0 -0
  60. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_cost_tracking.py +0 -0
  61. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_deliverable_presence_gate.py +0 -0
  62. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_doctor.py +0 -0
  63. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_driver_integration.py +0 -0
  64. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_driver_lock.py +0 -0
  65. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_duration_tracking.py +0 -0
  66. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_empty_files_escalation.py +0 -0
  67. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_ensure_feature_branch.py +0 -0
  68. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_events_redaction.py +0 -0
  69. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_extra_gates.py +0 -0
  70. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_force_full_close.py +0 -0
  71. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_gate_eval.py +0 -0
  72. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_gate_eval_calibration.py +0 -0
  73. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_gate_eval_intermediate_wiring.py +0 -0
  74. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_gate_eval_terminal_wiring.py +0 -0
  75. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_gh_backend.py +0 -0
  76. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_gh_features.py +0 -0
  77. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_git_env_isolation.py +0 -0
  78. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_hashed_denylist.py +0 -0
  79. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_hashed_denylist_ci.py +0 -0
  80. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_init_integration.py +0 -0
  81. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_leak_findings_redaction.py +0 -0
  82. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_leak_scan.py +0 -0
  83. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_leak_scan_content.py +0 -0
  84. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_learnings_query.py +0 -0
  85. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_legacy_4wu_terminal_flips.py +0 -0
  86. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lifecycle_integration.py +0 -0
  87. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_bare_produces_path.py +0 -0
  88. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_close_intermediate.py +0 -0
  89. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_close_wu.py +0 -0
  90. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_correlation_id.py +0 -0
  91. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
  92. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_oracle_env.py +0 -0
  93. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_plan_errors.py +0 -0
  94. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_plan_next_draft.py +0 -0
  95. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_plan_relative_import.py +0 -0
  96. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_produces_driver_helper.py +0 -0
  97. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_sections.py +0 -0
  98. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_lint_task_graph_yaml_selection.py +0 -0
  99. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_auto_archive.py +0 -0
  100. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_caveman_preamble.py +0 -0
  101. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_close_intermediate.py +0 -0
  102. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_defaults_by_type.py +0 -0
  103. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_effort.py +0 -0
  104. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_failure_note_cap.py +0 -0
  105. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_files_changed_guard.py +0 -0
  106. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_gate_budget.py +0 -0
  107. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_model_alias.py +0 -0
  108. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_orchestration.py +0 -0
  109. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_post_pass_invariant.py +0 -0
  110. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_reset_preserving_events.py +0 -0
  111. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_smoke_runner.py +0 -0
  112. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_unsandboxed.py +0 -0
  113. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_loop_zero_token_guard.py +0 -0
  114. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_migrate_legacy.py +0 -0
  115. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_miniyaml_equivalence.py +0 -0
  116. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_miniyaml_negative.py +0 -0
  117. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_planned_cost_lint.py +0 -0
  118. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_prepare_feature.py +0 -0
  119. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_produces_field.py +0 -0
  120. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_result_block.py +0 -0
  121. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_roadmap_archive_skill.py +0 -0
  122. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_roadmap_row_parser.py +0 -0
  123. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_data_in_sync.py +0 -0
  124. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_docs.py +0 -0
  125. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_init.py +0 -0
  126. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_manifest.py +0 -0
  127. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_resources.py +0 -0
  128. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_seed_sanity.py +0 -0
  129. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_upgrade.py +0 -0
  130. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_scaffold_wiring.py +0 -0
  131. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_skills_vendored_in_sync.py +0 -0
  132. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_squash_commit_hook_crash.py +0 -0
  133. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_template_closing_shapes.py +0 -0
  134. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_terminal_flip_ownership.py +0 -0
  135. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_terminal_flips.py +0 -0
  136. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_untracked_feature_folder.py +0 -0
  137. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_upgrade_integration.py +0 -0
  138. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_upgrade_merge_gate.py +0 -0
  139. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_validate_event.py +0 -0
  140. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_verdict_coupling.py +0 -0
  141. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_verify_empty_gate_set.py +0 -0
  142. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_version_consistency.py +0 -0
  143. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/tests/test_version_skew.py +0 -0
  144. {specfuse_loop-0.3.8 → specfuse_loop-0.3.10}/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.8
3
+ Version: 0.3.10
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.8"
7
+ version = "0.3.10"
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.10
@@ -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.8"
65
+ DRIVER_VERSION = "0.3.10"
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
@@ -507,6 +507,35 @@ def flush_events(events_path: Path, events: list) -> None:
507
507
  # --------------------------------------------------------------------------- #
508
508
 
509
509
 
510
+ # Known signatures of a gate whose oracle SILENTLY DEGRADED — the command
511
+ # exits 0 while its analyzer measured a strict subset of the real check, so a
512
+ # green gate is a hollow pass (issue #134). Each entry is (compiled pattern,
513
+ # human reason). A gate whose output matches while exiting 0 is forced to FAIL
514
+ # so the driver blocks honestly instead of certifying `met` on a subset oracle.
515
+ # These are language/tool-specific data, not detection logic — extend the list
516
+ # as new silent-degradation modes surface; the matching in verify() is generic.
517
+ DEGRADED_ORACLE_MARKERS: list[tuple["re.Pattern[str]", str]] = [
518
+ # Flutter/Dart custom_lint: the analyzer plugin can't resolve/build (e.g.
519
+ # network to pub.dev blocked in the sandbox), so `flutter analyze` drops to
520
+ # core lints only and still exits 0 — every custom_lint/riverpod_lint rule
521
+ # is invisible. Observed on FEAT-2026-0005 (28 warnings CI caught, loop 0).
522
+ (re.compile(r"error occurred while setting up the analyzer plugin",
523
+ re.IGNORECASE),
524
+ "custom_lint analyzer plugin did not load — 'flutter analyze' degraded to "
525
+ "core lints only; the warnings oracle measured a subset of the real check "
526
+ "and cannot certify zero warnings in this environment"),
527
+ ]
528
+
529
+
530
+ def detect_degraded_oracle(stdout: str) -> str | None:
531
+ """Return an honest reason string if gate output shows a silently degraded
532
+ oracle, else None. Matches against DEGRADED_ORACLE_MARKERS (issue #134)."""
533
+ for pattern, reason in DEGRADED_ORACLE_MARKERS:
534
+ if pattern.search(stdout):
535
+ return reason
536
+ return None
537
+
538
+
510
539
  def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
511
540
  """Extract (failure_class, failure_signature) from gate runner stdout.
512
541
 
@@ -1654,6 +1683,14 @@ def verify(wu: WorkUnit, feature_dir: Path,
1654
1683
  out, _ = proc.communicate(timeout=GATE_TIMEOUT_SECONDS)
1655
1684
  ok = proc.returncode == 0
1656
1685
  tail = (out or "").strip().splitlines()[-15:]
1686
+ # A green gate whose oracle silently degraded is a hollow pass
1687
+ # (issue #134): force FAIL and name the degradation honestly so the
1688
+ # log reads as "oracle couldn't measure it," not "code is clean."
1689
+ if ok:
1690
+ degraded = detect_degraded_oracle(out or "")
1691
+ if degraded:
1692
+ ok = False
1693
+ tail = tail + [f"DEGRADED ORACLE: {degraded}"]
1657
1694
  except subprocess.TimeoutExpired:
1658
1695
  try:
1659
1696
  os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
@@ -3174,6 +3211,11 @@ def run(
3174
3211
 
3175
3212
  print(f"\n[{time.strftime('%H:%M:%S')}] -- {wu.wu_id} "
3176
3213
  f"[{wu.type}] model={wu.model} effort={wu.effort}")
3214
+ # Summary line: the WU's title, so the log says WHAT is being
3215
+ # worked, not just its id. Skipped when title just echoes the id
3216
+ # (the parse default when a WU has no `# Title` heading).
3217
+ if wu.title and wu.title != wu.wu_id:
3218
+ print(f" ↳ {wu.title}")
3177
3219
  if dry_run:
3178
3220
  print(" (dry run — would dispatch)")
3179
3221
  wu.status = DONE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specfuse-loop
3
- Version: 0.3.8
3
+ Version: 0.3.10
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
@@ -53,6 +53,7 @@ tests/test_bookkeeping_commit_crash_run.py
53
53
  tests/test_bookkeeping_commit_hook_crash.py
54
54
  tests/test_closing_deliverable_guard.py
55
55
  tests/test_cost_tracking.py
56
+ tests/test_degraded_oracle.py
56
57
  tests/test_deliverable_presence_gate.py
57
58
  tests/test_doctor.py
58
59
  tests/test_driver_integration.py
@@ -0,0 +1,90 @@
1
+ #
2
+ # Copyright 2026 Specfuse contributors
3
+ # Licensed under the Apache License, Version 2.0. See LICENSE.
4
+ #
5
+ """Issue #134 — verify() must FAIL when a gate's oracle silently degraded.
6
+
7
+ A gate command can exit 0 while its analyzer degraded to a strict subset of the
8
+ real check. The observed case: `flutter analyze --fatal-warnings` cannot build a
9
+ `custom_lint` plugin in the loop sandbox (network to pub.dev blocked), prints
10
+ "An error occurred while setting up the analyzer plugin ...", then continues with
11
+ core lints only and exits 0 — "No issues found!". The gate passes; the real CI
12
+ (plugin loads there) fails on warnings the in-loop oracle never saw. A hollow
13
+ pass.
14
+
15
+ verify() must treat a known silent-degradation signature in gate output as a
16
+ gate FAILURE even on exit 0, with an honest message naming the degraded oracle,
17
+ so the driver blocks instead of certifying `met` on a subset check.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import unittest
23
+ from pathlib import Path
24
+
25
+ from tests._loop_loader import load_loop
26
+
27
+ loop = load_loop()
28
+
29
+
30
+ def make_wu(wu_type: str = "implementation"):
31
+ return loop.WorkUnit(
32
+ wu_id="FEAT-2026-9999/T01",
33
+ file=Path("/tmp/does-not-matter.md"),
34
+ depends_on=[],
35
+ type=wu_type,
36
+ model="claude-haiku-4-5-20251001",
37
+ status="pending",
38
+ attempts=0,
39
+ title="test fixture",
40
+ body="(body unused)",
41
+ )
42
+
43
+
44
+ # A shell command that reproduces the degraded-oracle shape: emit the analyzer
45
+ # plugin-setup error to stdout, report "No issues found!", then exit 0.
46
+ _DEGRADED_CMD = (
47
+ "printf '%s\\n' "
48
+ "'An error occurred while setting up the analyzer plugin custom_lint' "
49
+ "'No issues found!'; true"
50
+ )
51
+
52
+
53
+ class TestDegradedOracle(unittest.TestCase):
54
+
55
+ def test_plugin_setup_error_fails_gate_despite_exit_zero(self):
56
+ cfg = {"code": [{"name": "warnings", "command": _DEGRADED_CMD}]}
57
+ ok, msg = loop.verify(make_wu(), Path("/tmp/feat"), cfg=cfg)
58
+ self.assertFalse(
59
+ ok, "exit 0 with a plugin-setup error must FAIL — oracle degraded"
60
+ )
61
+ self.assertIn("### warnings: FAIL", msg)
62
+ self.assertIn("DEGRADED ORACLE", msg)
63
+
64
+ def test_message_is_honest_about_the_cause(self):
65
+ cfg = {"code": [{"name": "warnings", "command": _DEGRADED_CMD}]}
66
+ _, msg = loop.verify(make_wu(), Path("/tmp/feat"), cfg=cfg)
67
+ # The honest reason (not just the echoed marker) reaches the log: names
68
+ # the degradation and that the oracle measured a subset, not a warning.
69
+ self.assertIn("did not load", msg)
70
+ self.assertIn("subset", msg)
71
+
72
+ def test_clean_pass_still_passes(self):
73
+ # Same "No issues found!" success WITHOUT the degradation marker: passes.
74
+ cfg = {"code": [{"name": "warnings",
75
+ "command": "printf '%s\\n' 'No issues found!'; true"}]}
76
+ ok, msg = loop.verify(make_wu(), Path("/tmp/feat"), cfg=cfg)
77
+ self.assertTrue(ok, "clean exit 0 with no degradation marker must pass")
78
+ self.assertIn("### warnings: PASS", msg)
79
+
80
+ def test_marker_does_not_override_a_real_failure(self):
81
+ # Command exits non-zero AND has the marker: still a FAIL (not masked).
82
+ cfg = {"code": [{"name": "warnings",
83
+ "command": _DEGRADED_CMD.replace("; true", "; false")}]}
84
+ ok, msg = loop.verify(make_wu(), Path("/tmp/feat"), cfg=cfg)
85
+ self.assertFalse(ok)
86
+ self.assertIn("### warnings: FAIL", msg)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ unittest.main()
@@ -5,7 +5,8 @@
5
5
  """Reference implementation and tests for the roadmap-add skill algorithm.
6
6
 
7
7
  The skill appends a new planned feature's table row and detail section to
8
- roadmap.md, auto-picking the next FEAT-YYYY-NNNN by scanning three sources.
8
+ roadmap.md, auto-picking the next FEAT-YYYY-NNNN by scanning four sources
9
+ (the fourth — GitHub issue/PR-reserved IDs — added for specfuse#16).
9
10
 
10
11
  Exercises:
11
12
  (a) headless mode appends a row and detail section in the right places
@@ -33,19 +34,29 @@ FEAT_ROW_RE = re.compile(r"^\| (FEAT-\d{4}-\d{4})")
33
34
  PLAN_FEAT_RE = re.compile(r"^feature_id:\s*(FEAT-\d{4}-\d{4})")
34
35
 
35
36
 
36
- def scan_feat_ids(roadmap_path: Path, features_dir: Path, extra_paths: list[Path]) -> dict:
37
- """Scan FEAT-YYYY-NNNN IDs from three sources.
37
+ def scan_feat_ids(
38
+ roadmap_path: Path,
39
+ features_dir: Path,
40
+ extra_paths: list[Path],
41
+ github_reserved: dict[str, str] | None = None,
42
+ ) -> dict:
43
+ """Scan FEAT-YYYY-NNNN IDs from four sources.
38
44
 
39
45
  Source (a): table rows in roadmap_path (lines matching ``^| FEAT-``).
40
46
  Source (b): ``feature_id:`` lines in features_dir/*/PLAN.md.
41
47
  Source (c): any occurrence in extra_paths (LEARNINGS.md, RETROSPECTIVE.md).
48
+ Source (d): GitHub issue/PR titles+bodies — modelled here as an injected
49
+ ``github_reserved`` map ``{feat_id: "GitHub issue/PR #<n>"}`` so the
50
+ fold-into-max + collision logic is exercised hermetically (no network).
51
+ ``None`` models GitHub being unreachable: the skill degrades gracefully
52
+ and simply omits source (d).
42
53
 
43
- Returns dict mapping feat_id (str) -> (filepath_str, line_no) for the
54
+ Returns dict mapping feat_id (str) -> (location_str, line_no) for the
44
55
  first occurrence of each ID seen.
45
56
  """
46
57
  sources: dict[str, tuple[str, int]] = {}
47
58
 
48
- def record(feat_id: str, filepath: Path, lineno: int) -> None:
59
+ def record(feat_id: str, filepath, lineno: int) -> None:
49
60
  if feat_id not in sources:
50
61
  sources[feat_id] = (str(filepath), lineno)
51
62
 
@@ -71,6 +82,13 @@ def scan_feat_ids(roadmap_path: Path, features_dir: Path, extra_paths: list[Path
71
82
  for m in FEAT_RE.finditer(line):
72
83
  record(m.group(0), path, lineno)
73
84
 
85
+ # (d) GitHub issue/PR-reserved IDs (None = GitHub unreachable, skip source).
86
+ if github_reserved:
87
+ for feat_id, ref in github_reserved.items():
88
+ # location is the issue/PR ref; lineno 0 (not a file line)
89
+ if feat_id not in sources:
90
+ sources[feat_id] = (ref, 0)
91
+
74
92
  return sources
75
93
 
76
94
 
@@ -449,6 +467,40 @@ class TestNextFeatId(unittest.TestCase):
449
467
  self.assertIn("gap", str(ctx.exception).lower())
450
468
  self.assertIn("0002", str(ctx.exception))
451
469
 
470
+ # ── Source (d): GitHub issue/PR-reserved IDs (specfuse#16) ──────────────
471
+
472
+ def test_github_reserved_id_raises_max(self):
473
+ """A FEAT ID reserved only on a GitHub issue (no roadmap row) must be
474
+ folded into the max so the next ID does not collide with it."""
475
+ # roadmap/plan/learnings top out at 0010; GitHub reserves 0011.
476
+ gh = {"FEAT-2026-0011": "GitHub issue #345"}
477
+ sources = scan_feat_ids(
478
+ self.roadmap, self.features_dir, [self.learnings], github_reserved=gh
479
+ )
480
+ feat_id, ordinal = next_feat_id(2026, sources)
481
+ self.assertEqual(feat_id, "FEAT-2026-0012")
482
+ self.assertEqual(ordinal, 12)
483
+
484
+ def test_github_unreachable_degrades_gracefully(self):
485
+ """github_reserved=None models GitHub being unreachable: the scan omits
486
+ source (d) and proceeds on the three local sources — no hard failure."""
487
+ sources = scan_feat_ids(
488
+ self.roadmap, self.features_dir, [self.learnings], github_reserved=None
489
+ )
490
+ feat_id, _ = next_feat_id(2026, sources)
491
+ self.assertEqual(feat_id, "FEAT-2026-0011") # local-only max=0010
492
+
493
+ def test_github_reserved_beyond_local_max_surfaces_gap(self):
494
+ """A reserved ID beyond the local max exposes the intervening ordinals
495
+ as genuine gaps (roadmap→0010, issue reserves 0016 → 0011–0015 gap)."""
496
+ gh = {"FEAT-2026-0016": "GitHub issue #345"}
497
+ sources = scan_feat_ids(
498
+ self.roadmap, self.features_dir, [self.learnings], github_reserved=gh
499
+ )
500
+ with self.assertRaises(ValueError) as ctx:
501
+ next_feat_id(2026, sources)
502
+ self.assertIn("gap", str(ctx.exception).lower())
503
+
452
504
 
453
505
  class TestCollisionRejection(unittest.TestCase):
454
506
  """(c) ID collision is rejected with a message naming the conflicting source."""
@@ -530,6 +582,42 @@ class TestCollisionRejection(unittest.TestCase):
530
582
  # Caller would not invoke add_feature on collision; verify snapshot unchanged
531
583
  self.assertEqual(self.roadmap.read_text(), snapshot)
532
584
 
585
+ def test_collision_detected_for_github_reserved_id(self):
586
+ """specfuse#16: an ID reserved only on a GitHub issue/PR (no local
587
+ source) must be caught by the collision check and name the GitHub ref."""
588
+ gh = {"FEAT-2026-0016": "GitHub issue #345"}
589
+ sources = scan_feat_ids(
590
+ self.roadmap, self.features_dir, [self.learnings], github_reserved=gh
591
+ )
592
+ collides, filepath, lineno = check_collision("FEAT-2026-0016", sources)
593
+ self.assertTrue(collides, "GitHub-reserved ID must collide")
594
+ self.assertIn("GitHub", filepath)
595
+
596
+
597
+ class TestSkillDocumentsGitHubSource(unittest.TestCase):
598
+ """The skill prose must document source (d) + graceful offline degradation
599
+ (specfuse#16). Guards against the four-source scan silently regressing to
600
+ three in the authored SKILL.md."""
601
+
602
+ def setUp(self):
603
+ repo_root = Path(__file__).resolve().parent.parent
604
+ self.skill = (
605
+ repo_root / "plugins" / "specfuse" / "skills"
606
+ / "roadmap-add" / "SKILL.md"
607
+ ).read_text()
608
+
609
+ def test_declares_four_sources(self):
610
+ self.assertIn("four sources", self.skill)
611
+
612
+ def test_documents_github_source(self):
613
+ self.assertIn("Source (d)", self.skill)
614
+ self.assertIn("gh search issues", self.skill)
615
+
616
+ def test_documents_graceful_degradation(self):
617
+ # A WARN path so offline runs continue rather than hard-failing.
618
+ self.assertIn("WARN", self.skill)
619
+ self.assertIn("offline", self.skill.lower())
620
+
533
621
 
534
622
  if __name__ == "__main__":
535
623
  unittest.main()
@@ -1 +0,0 @@
1
- 0.3.8
File without changes
File without changes
File without changes
File without changes