specfuse-loop 0.3.20__tar.gz → 0.3.21__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {specfuse_loop-0.3.20/specfuse_loop.egg-info → specfuse_loop-0.3.21}/PKG-INFO +1 -1
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/pyproject.toml +1 -1
- specfuse_loop-0.3.21/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/lint_plan.py +27 -1
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/loop.py +321 -34
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/SOURCES.txt +3 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_attempt_outcome_emission.py +50 -0
- specfuse_loop-0.3.21/tests/test_lifetime_accounting.py +240 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_planned_cost_lint.py +72 -0
- specfuse_loop-0.3.21/tests/test_produces_in_diff.py +264 -0
- specfuse_loop-0.3.21/tests/test_spinning_rearm_gate.py +308 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_terminal_flips.py +85 -0
- specfuse_loop-0.3.20/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/LICENSE +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/NOTICE +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/README.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/setup.cfg +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_lint_verification_reference.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_attempt_evidence.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.20 → specfuse_loop-0.3.21}/tests/test_wu_execution_metadata.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specfuse-loop"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.21"
|
|
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.21
|
|
@@ -248,12 +248,23 @@ def _find_task_graph_block(body: str) -> dict | None:
|
|
|
248
248
|
return None
|
|
249
249
|
|
|
250
250
|
|
|
251
|
+
# Ceremony WU types systematically under-estimated in the field: close and
|
|
252
|
+
# plan-next WUs ran 2.8-5.2x over on $2-3 estimates across FEAT-2026-0049
|
|
253
|
+
# (clabonte/generator), poisoning every calibration built on the plans (#201).
|
|
254
|
+
# The floor is a WARN threshold, not a cap — estimates below it are almost
|
|
255
|
+
# certainly wishful, not cheap.
|
|
256
|
+
CEREMONY_COST_FLOOR_USD = 5.0
|
|
257
|
+
_CEREMONY_TYPES = frozenset({"close", "close-intermediate", "plan-next"})
|
|
258
|
+
|
|
259
|
+
|
|
251
260
|
def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
|
|
252
261
|
"""Emit WARN for missing planned_cost_usd on WUs and PLAN.md.
|
|
253
262
|
|
|
254
263
|
Sealed WUs (wu status=done AND plan status=done) are skipped silently —
|
|
255
264
|
backfilling cost estimates on history is pointless. Active or draft WUs
|
|
256
|
-
get the WARN.
|
|
265
|
+
get the WARN. Ceremony-type WUs (close/close-intermediate/plan-next)
|
|
266
|
+
with a planned cost below CEREMONY_COST_FLOOR_USD get a floor WARN
|
|
267
|
+
(#201). PLAN.md is compared against the sum of WU planned costs;
|
|
257
268
|
delta > 10% emits a separate WARN naming the delta. Never raises or
|
|
258
269
|
appends to an errors list — all findings are WARN-only (exit code 0).
|
|
259
270
|
"""
|
|
@@ -283,6 +294,21 @@ def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
|
|
|
283
294
|
)
|
|
284
295
|
if planned is not None:
|
|
285
296
|
wu_sum += float(planned)
|
|
297
|
+
# Floor applies to POSITIVE estimates only: the observed
|
|
298
|
+
# failure is a real-but-wishful $2-3 estimate anchoring
|
|
299
|
+
# calibration, not an explicit 0.00 (the scaffold's
|
|
300
|
+
# "unestimated" placeholder, already visible as such).
|
|
301
|
+
if (not is_sealed
|
|
302
|
+
and wfm.get("type") in _CEREMONY_TYPES
|
|
303
|
+
and 0 < float(planned) < CEREMONY_COST_FLOOR_USD):
|
|
304
|
+
print(
|
|
305
|
+
f"WARN: {wfile}: planned_cost_usd "
|
|
306
|
+
f"${float(planned):.2f} is below the "
|
|
307
|
+
f"${CEREMONY_COST_FLOOR_USD:.2f} floor for "
|
|
308
|
+
f"'{wfm.get('type')}' WUs. Ceremony WUs ran 2.8-5.2x "
|
|
309
|
+
f"over on $2-3 estimates (FEAT-2026-0049); raise the "
|
|
310
|
+
f"estimate rather than anchoring calibration on it."
|
|
311
|
+
)
|
|
286
312
|
|
|
287
313
|
wu_sum = round(wu_sum, 2)
|
|
288
314
|
|
|
@@ -41,6 +41,7 @@ from __future__ import annotations
|
|
|
41
41
|
|
|
42
42
|
import argparse
|
|
43
43
|
import datetime as dt
|
|
44
|
+
import fnmatch
|
|
44
45
|
import hashlib
|
|
45
46
|
import json
|
|
46
47
|
import logging
|
|
@@ -63,7 +64,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
63
64
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
64
65
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
65
66
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
66
|
-
DRIVER_VERSION = "0.3.
|
|
67
|
+
DRIVER_VERSION = "0.3.21"
|
|
67
68
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
68
69
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
69
70
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -624,6 +625,14 @@ def _is_noninformative_signature(sig: "str | None") -> bool:
|
|
|
624
625
|
return bool(_FENCE_RE.match(stripped))
|
|
625
626
|
|
|
626
627
|
|
|
628
|
+
# Maven surefire failing-test line: "[ERROR] SomeClassTest.method:123 ..."
|
|
629
|
+
# (also matches the IT / Tests naming conventions). Group 1 is Class.method —
|
|
630
|
+
# the per-test identity a retry can act on (#207).
|
|
631
|
+
_SUREFIRE_FAILING_TEST_RE = re.compile(
|
|
632
|
+
r"^\[ERROR\]\s+([A-Z]\w*(?:Test|Tests|IT)\w*\.\w+)", re.MULTILINE,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
627
636
|
def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
|
|
628
637
|
"""Extract (failure_class, failure_signature) from gate runner stdout.
|
|
629
638
|
|
|
@@ -645,14 +654,20 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
|
|
|
645
654
|
failure_class = _GATE_CLASS_MAP.get(gate_name, "other")
|
|
646
655
|
after_lines = stdout[m.end():].splitlines()[:50]
|
|
647
656
|
after_text = "\n".join(after_lines)
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
657
|
+
# Per-class patterns, tried in order (#207): pytest/unittest first, then
|
|
658
|
+
# Maven surefire — whose failures previously fell through to the first-
|
|
659
|
+
# informative-line heuristic and collapsed onto a generic Maven line,
|
|
660
|
+
# identical across distinct failing-test sets.
|
|
661
|
+
_SIG_PATTERNS: dict[str, list[re.Pattern[str]]] = {
|
|
662
|
+
"tests": [
|
|
663
|
+
re.compile(r"^FAIL: (test_\S+)", re.MULTILINE),
|
|
664
|
+
_SUREFIRE_FAILING_TEST_RE,
|
|
665
|
+
],
|
|
666
|
+
"lint": [re.compile(r"\b([A-Z]\d{3,4})\b")],
|
|
667
|
+
"security": [re.compile(r"Issue: \[(B\d+)")],
|
|
668
|
+
"coverage": [re.compile(r"^([^\s]+\.py)\s+\d+\s+\d+", re.MULTILINE)],
|
|
653
669
|
}
|
|
654
|
-
pattern
|
|
655
|
-
if pattern:
|
|
670
|
+
for pattern in _SIG_PATTERNS.get(failure_class, []):
|
|
656
671
|
sm = pattern.search(after_text)
|
|
657
672
|
if sm:
|
|
658
673
|
sig = sm.group(1)
|
|
@@ -703,6 +718,16 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
|
|
|
703
718
|
"""
|
|
704
719
|
_KW = re.compile(r"FAIL|Error|Exception|Traceback", re.IGNORECASE)
|
|
705
720
|
relevant = [ln for ln in stdout.splitlines() if _KW.search(ln)]
|
|
721
|
+
# Hoist surefire failing-test lines to the front (#207): Maven emits
|
|
722
|
+
# dozens of boilerplate [ERROR] wrapper lines that all pass the keyword
|
|
723
|
+
# filter, so the head+tail budget reliably crowded out the actual
|
|
724
|
+
# failing-test list — five consecutive FEAT-2026-0049/T06 retries carried
|
|
725
|
+
# the identical information-free Maven tail while the failing tests sat
|
|
726
|
+
# mid-output. The list is the one part a retry can act on; put it first.
|
|
727
|
+
surefire = [ln for ln in relevant if _SUREFIRE_FAILING_TEST_RE.match(ln)]
|
|
728
|
+
if surefire:
|
|
729
|
+
rest = [ln for ln in relevant if not _SUREFIRE_FAILING_TEST_RE.match(ln)]
|
|
730
|
+
relevant = surefire + rest
|
|
706
731
|
text = "\n".join(relevant) if relevant else stdout
|
|
707
732
|
encoded = text.encode("utf-8")
|
|
708
733
|
if len(encoded) <= max_chars:
|
|
@@ -1396,6 +1421,50 @@ def detect_rearm_dispatch(wu: WorkUnit) -> bool:
|
|
|
1396
1421
|
return isinstance(cost_usd, (int, float)) and float(cost_usd) > 0
|
|
1397
1422
|
|
|
1398
1423
|
|
|
1424
|
+
def rearm_reproduction_gate(wu: WorkUnit) -> tuple[bool, str]:
|
|
1425
|
+
"""Refuse re-dispatch of a spinning-escalated WU without reproduction (#200).
|
|
1426
|
+
|
|
1427
|
+
After a spinning_signature_repeat escalation the driver used to re-dispatch
|
|
1428
|
+
a re-armed WU with no evidence the escalated failure was ever reproduced in
|
|
1429
|
+
full — FEAT-2026-0049/WU-06 was re-armed twice on test-subset diagnoses;
|
|
1430
|
+
the third re-arm, the first to reproduce the full suite, found the real
|
|
1431
|
+
defect immediately. Before dispatching a WU whose frontmatter carries
|
|
1432
|
+
``escalation_reason: spinning_signature_repeat`` (stamped at escalation
|
|
1433
|
+
time) with ``re_arm_count > 0``, require one of:
|
|
1434
|
+
|
|
1435
|
+
- ``reproduced_signature`` equal to ``escalation_failure_signature`` —
|
|
1436
|
+
the operator attests the exact escalated failure was reproduced before
|
|
1437
|
+
re-arming, or
|
|
1438
|
+
- ``re_arm_override: true`` — an explicit operator bypass.
|
|
1439
|
+
|
|
1440
|
+
Returns (True, "") when the WU may dispatch (including every WU that never
|
|
1441
|
+
escalated on spinning, and first-time dispatches). Returns (False, reason)
|
|
1442
|
+
when the dispatch must be refused; the caller re-blocks the WU.
|
|
1443
|
+
"""
|
|
1444
|
+
if not wu.file.is_file():
|
|
1445
|
+
return True, ""
|
|
1446
|
+
fm, _ = read_frontmatter(wu.file)
|
|
1447
|
+
if fm.get("escalation_reason") != "spinning_signature_repeat":
|
|
1448
|
+
return True, ""
|
|
1449
|
+
re_arm_count = fm.get("re_arm_count", 0)
|
|
1450
|
+
if not isinstance(re_arm_count, int) or isinstance(re_arm_count, bool) \
|
|
1451
|
+
or re_arm_count <= 0:
|
|
1452
|
+
return True, ""
|
|
1453
|
+
if fm.get("re_arm_override") in (True, "true", "True"):
|
|
1454
|
+
return True, ""
|
|
1455
|
+
signature = str(fm.get("escalation_failure_signature") or "")
|
|
1456
|
+
reproduced = str(fm.get("reproduced_signature") or "")
|
|
1457
|
+
if signature and reproduced == signature:
|
|
1458
|
+
return True, ""
|
|
1459
|
+
return False, (
|
|
1460
|
+
f"WU escalated on spinning_signature_repeat "
|
|
1461
|
+
f"(signature: {signature or '<unrecorded>'}) and was re-armed without "
|
|
1462
|
+
f"reproduction evidence. Reproduce the exact failure and set "
|
|
1463
|
+
f"`reproduced_signature: {signature or '<signature>'}` in the WU "
|
|
1464
|
+
f"frontmatter, or set `re_arm_override: true` to bypass explicitly."
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
|
|
1399
1468
|
def fold_cumulative_on_rearm(wu: WorkUnit, backend: Backend) -> None:
|
|
1400
1469
|
"""Fold the prior dispatch cycle's cost/token/duration into cumulative fields.
|
|
1401
1470
|
|
|
@@ -1425,6 +1494,22 @@ def fold_cumulative_on_rearm(wu: WorkUnit, backend: Backend) -> None:
|
|
|
1425
1494
|
backend.set_wu(wu, "cumulative_input_tokens", cum_input)
|
|
1426
1495
|
backend.set_wu(wu, "cumulative_output_tokens", cum_output)
|
|
1427
1496
|
|
|
1497
|
+
# Lifetime attempts (#199). unblock-wu resets `attempts: 0` BEFORE this
|
|
1498
|
+
# fold runs, so the prior cycle's count is unrecoverable from `attempts`
|
|
1499
|
+
# itself — it survives only in re_arm_history[].prior_attempts. Recompute
|
|
1500
|
+
# (not increment) the sum so the write is idempotent across re-entries,
|
|
1501
|
+
# matching the fold's cost-fold idempotence contract. Entries a pre-#199
|
|
1502
|
+
# unblock-wu wrote without prior_attempts contribute 0.
|
|
1503
|
+
history = fm.get("re_arm_history") or []
|
|
1504
|
+
cum_attempts = 0
|
|
1505
|
+
if isinstance(history, list):
|
|
1506
|
+
for entry in history:
|
|
1507
|
+
if isinstance(entry, dict):
|
|
1508
|
+
prior = entry.get("prior_attempts")
|
|
1509
|
+
if isinstance(prior, int) and not isinstance(prior, bool):
|
|
1510
|
+
cum_attempts += prior
|
|
1511
|
+
backend.set_wu(wu, "cumulative_attempts", cum_attempts)
|
|
1512
|
+
|
|
1428
1513
|
# Reset per-cycle fields so the new cycle's write_cost_to_wu starts clean.
|
|
1429
1514
|
backend.set_wu(wu, "cost_usd", 0.0)
|
|
1430
1515
|
backend.set_wu(wu, "duration_seconds", 0.0)
|
|
@@ -1452,14 +1537,15 @@ def gate_budget_usd(gate_file: Path) -> float | None:
|
|
|
1452
1537
|
|
|
1453
1538
|
|
|
1454
1539
|
def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
|
|
1455
|
-
"""Sum
|
|
1540
|
+
"""Sum lifetime cost across the gate's done WUs (closing-sequence included).
|
|
1456
1541
|
|
|
1457
1542
|
Reads each WU file's frontmatter from `gate["work_units"]` and adds
|
|
1458
|
-
`cost_usd`
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
is
|
|
1543
|
+
`cost_usd` PLUS `cumulative_cost_usd` (prior re-arm cycles' spend, folded
|
|
1544
|
+
by fold_cumulative_on_rearm — #199) when the WU's status is "done". WUs
|
|
1545
|
+
whose frontmatter omits both — cost tracking off, or the attempt didn't
|
|
1546
|
+
record a cost — contribute 0.0. `plan` is the feature frontmatter dict and
|
|
1547
|
+
is accepted for signature symmetry with the broader gate-budget helpers;
|
|
1548
|
+
the spent total is derived from WU files alone.
|
|
1463
1549
|
"""
|
|
1464
1550
|
del plan # signature symmetry — sum is derived from WU files only
|
|
1465
1551
|
total = 0.0
|
|
@@ -1473,11 +1559,16 @@ def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
|
|
|
1473
1559
|
fm, _ = read_frontmatter(wu_path)
|
|
1474
1560
|
if fm.get("status") != "done":
|
|
1475
1561
|
continue
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1562
|
+
# Lifetime spend, not per-cycle (#199): fold_cumulative_on_rearm
|
|
1563
|
+
# moves each prior dispatch cycle's cost into cumulative_cost_usd and
|
|
1564
|
+
# zeroes cost_usd, so summing cost_usd alone undercounts every
|
|
1565
|
+
# re-armed WU (FEAT-2026-0049/WU-06 read as $2.75 of a $30.29 spend).
|
|
1566
|
+
for key in ("cost_usd", "cumulative_cost_usd"):
|
|
1567
|
+
cost = fm.get(key)
|
|
1568
|
+
if isinstance(cost, bool):
|
|
1569
|
+
continue
|
|
1570
|
+
if isinstance(cost, (int, float)):
|
|
1571
|
+
total += float(cost)
|
|
1481
1572
|
return total
|
|
1482
1573
|
|
|
1483
1574
|
|
|
@@ -2611,6 +2702,48 @@ def _legacy_4wu_terminal_close_complete(
|
|
|
2611
2702
|
return required.issubset(have_done)
|
|
2612
2703
|
|
|
2613
2704
|
|
|
2705
|
+
def revert_terminal_surfaces(
|
|
2706
|
+
wu: WorkUnit, feature_dir: Path, repo_root: Path,
|
|
2707
|
+
) -> list[Path]:
|
|
2708
|
+
"""Revert agent-written terminal surfaces after a hedged close verdict (#195).
|
|
2709
|
+
|
|
2710
|
+
A close WU that passes with a hedged verdict (met_locally / partially_met /
|
|
2711
|
+
not_met) must leave every terminal surface un-flipped, but the agent's own
|
|
2712
|
+
close ceremony may already have written PLAN.md `done` and the roadmap row
|
|
2713
|
+
`done` before the driver read the verdict. Reverting only PLAN.md — the
|
|
2714
|
+
pre-#195 behavior — leaves the two surfaces disagreeing about whether the
|
|
2715
|
+
feature is complete. Revert the same surface set fire_terminal_flips owns:
|
|
2716
|
+
PLAN.md status and the roadmap Status cell (the gate file is not flipped
|
|
2717
|
+
until the post-loop awaiting_review write, so there is nothing to revert
|
|
2718
|
+
there). Returns the modified Paths for one bookkeeping commit.
|
|
2719
|
+
"""
|
|
2720
|
+
modified: list[Path] = []
|
|
2721
|
+
feature_id = wu.wu_id.rsplit("/", 1)[0]
|
|
2722
|
+
|
|
2723
|
+
plan_path = feature_dir / "PLAN.md"
|
|
2724
|
+
if plan_path.exists():
|
|
2725
|
+
plan_fm, _ = read_frontmatter(plan_path)
|
|
2726
|
+
if plan_fm.get("status") == "done":
|
|
2727
|
+
write_frontmatter_field(plan_path, "status", "active")
|
|
2728
|
+
modified.append(plan_path)
|
|
2729
|
+
|
|
2730
|
+
roadmap_path = repo_root / ".specfuse" / "roadmap.md"
|
|
2731
|
+
if roadmap_path.exists():
|
|
2732
|
+
roadmap_text = roadmap_path.read_text()
|
|
2733
|
+
parsed = _parse_roadmap_row(roadmap_text, feature_id)
|
|
2734
|
+
if parsed is not None and parsed["columns"].get("Status", "") == "done":
|
|
2735
|
+
status_start, status_end = parsed["cell_spans"]["Status"]
|
|
2736
|
+
status_cell = roadmap_text[status_start:status_end]
|
|
2737
|
+
roadmap_path.write_text(
|
|
2738
|
+
roadmap_text[:status_start]
|
|
2739
|
+
+ status_cell.replace("done", "active", 1)
|
|
2740
|
+
+ roadmap_text[status_end:]
|
|
2741
|
+
)
|
|
2742
|
+
modified.append(roadmap_path)
|
|
2743
|
+
|
|
2744
|
+
return modified
|
|
2745
|
+
|
|
2746
|
+
|
|
2614
2747
|
def fire_terminal_flips(wu: WorkUnit, feature_dir: Path, repo_root: Path) -> list[Path]:
|
|
2615
2748
|
"""Flip terminal gate → passed, roadmap row → done, call auto_archive_feature.
|
|
2616
2749
|
|
|
@@ -2621,6 +2754,26 @@ def fire_terminal_flips(wu: WorkUnit, feature_dir: Path, repo_root: Path) -> lis
|
|
|
2621
2754
|
modified: set[Path] = set()
|
|
2622
2755
|
feature_id = wu.wu_id.rsplit("/", 1)[0]
|
|
2623
2756
|
|
|
2757
|
+
# Evaluate the verdict BEFORE writing any surface (#195). The pre-#195
|
|
2758
|
+
# shape flipped gate + roadmap unconditionally and only gated the PLAN
|
|
2759
|
+
# flip on the verdict, so a hedged close left a split state (roadmap
|
|
2760
|
+
# `done`, PLAN `active`). A verdict that is present but not `met` now
|
|
2761
|
+
# touches nothing. verdict absent (None) keeps the legacy 4-WU behavior:
|
|
2762
|
+
# those plan-next WUs carry no verdict field and still need the gate and
|
|
2763
|
+
# roadmap flips (their PLAN flip stays with the plan-next agent).
|
|
2764
|
+
disk_verdict = None
|
|
2765
|
+
if wu.file.is_file():
|
|
2766
|
+
wu_fm, _ = read_frontmatter(wu.file)
|
|
2767
|
+
disk_verdict = wu_fm.get("verdict") or None
|
|
2768
|
+
if disk_verdict is not None and not verdict_permits_terminal_flips(disk_verdict):
|
|
2769
|
+
logging.info(
|
|
2770
|
+
"fire_terminal_flips: verdict %r does not permit terminal flips"
|
|
2771
|
+
" — no surface written for %s",
|
|
2772
|
+
disk_verdict,
|
|
2773
|
+
wu.wu_id,
|
|
2774
|
+
)
|
|
2775
|
+
return []
|
|
2776
|
+
|
|
2624
2777
|
_, gates = load_graph(feature_dir)
|
|
2625
2778
|
if not gates:
|
|
2626
2779
|
logging.warning("fire_terminal_flips: no gates in PLAN.md for %s", wu.wu_id)
|
|
@@ -2702,15 +2855,11 @@ def fire_terminal_flips(wu: WorkUnit, feature_dir: Path, repo_root: Path) -> lis
|
|
|
2702
2855
|
# assert_terminal_flips_fired: the auto-close path writes verdict=met to the
|
|
2703
2856
|
# WU file via mark_close_wu_auto_closed but leaves the in-memory wu.verdict
|
|
2704
2857
|
# None, so disk is the authoritative source for both paths.
|
|
2705
|
-
#
|
|
2858
|
+
# disk_verdict was read at the top of this function. The legacy 4-WU
|
|
2706
2859
|
# close sequence reaches here with a plan-next WU that carries no verdict
|
|
2707
|
-
# field (and whose file may be a synthetic stub in tests); a missing file
|
|
2708
|
-
#
|
|
2709
|
-
#
|
|
2710
|
-
disk_verdict = None
|
|
2711
|
-
if wu.file.is_file():
|
|
2712
|
-
wu_fm, _ = read_frontmatter(wu.file)
|
|
2713
|
-
disk_verdict = wu_fm.get("verdict") or None
|
|
2860
|
+
# field (and whose file may be a synthetic stub in tests); a missing file
|
|
2861
|
+
# simply skips the PLAN flip, leaving legacy behavior unchanged (those
|
|
2862
|
+
# features flip PLAN via the plan-next agent, as before).
|
|
2714
2863
|
if not verdict_permits_terminal_flips(disk_verdict):
|
|
2715
2864
|
logging.info(
|
|
2716
2865
|
"fire_terminal_flips: verdict %r does not permit terminal flips"
|
|
@@ -3666,6 +3815,35 @@ def assert_declared_deliverables(wu: WorkUnit) -> tuple[bool, str]:
|
|
|
3666
3815
|
return True, ""
|
|
3667
3816
|
|
|
3668
3817
|
|
|
3818
|
+
def assert_produces_in_diff(
|
|
3819
|
+
wu: WorkUnit, touched: list[str],
|
|
3820
|
+
) -> tuple[bool, str]:
|
|
3821
|
+
"""Cross-check declared ``produces:`` entries against the squash diff (#198).
|
|
3822
|
+
|
|
3823
|
+
``assert_declared_deliverables`` is presence-only, so a WU whose
|
|
3824
|
+
``produces:`` paths are pre-existing files it was supposed to MODIFY passes
|
|
3825
|
+
it without touching them — the FEAT-2026-0049/T06 shape: ``done`` touching
|
|
3826
|
+
only gate docs while both declared src/main deliverables (existing files)
|
|
3827
|
+
sat unchanged. Every ``produces:`` entry must match at least one path in
|
|
3828
|
+
*touched* (the squash's changed-path list), literally or as a glob.
|
|
3829
|
+
Opt-out mirrors the presence gate: empty ``produces:`` never fires.
|
|
3830
|
+
Returns (False, summary) naming every unmatched entry.
|
|
3831
|
+
"""
|
|
3832
|
+
if not wu.produces:
|
|
3833
|
+
return True, ""
|
|
3834
|
+
unmatched = []
|
|
3835
|
+
for raw in wu.produces:
|
|
3836
|
+
entry = str(raw)
|
|
3837
|
+
if not any(t == entry or fnmatch.fnmatch(t, entry) for t in touched):
|
|
3838
|
+
unmatched.append(entry)
|
|
3839
|
+
if unmatched:
|
|
3840
|
+
return False, (
|
|
3841
|
+
"declared produces path(s) not in this WU's squash diff: "
|
|
3842
|
+
+ ", ".join(unmatched)
|
|
3843
|
+
)
|
|
3844
|
+
return True, ""
|
|
3845
|
+
|
|
3846
|
+
|
|
3669
3847
|
# --------------------------------------------------------------------------- #
|
|
3670
3848
|
# Post-pass driver-state invariants (FEAT-2026-0017/T01) #
|
|
3671
3849
|
# --------------------------------------------------------------------------- #
|
|
@@ -4055,6 +4233,30 @@ def run(
|
|
|
4055
4233
|
)])
|
|
4056
4234
|
# Fall through to existing close-WU dispatch path
|
|
4057
4235
|
|
|
4236
|
+
# Spinning re-arm reproduction gate (#200): a WU that escalated
|
|
4237
|
+
# on spinning_signature_repeat may not be re-dispatched until
|
|
4238
|
+
# the exact escalated failure was reproduced (or the operator
|
|
4239
|
+
# explicitly overrode). Fires BEFORE any dispatch state change;
|
|
4240
|
+
# on refusal the WU goes back to blocked_human with an
|
|
4241
|
+
# actionable message and the gate halts as usual.
|
|
4242
|
+
repro_ok, repro_reason = rearm_reproduction_gate(wu)
|
|
4243
|
+
if not repro_ok:
|
|
4244
|
+
backend.set_wu(wu, "status", "blocked_human")
|
|
4245
|
+
flush_events(events_path, [build_event(
|
|
4246
|
+
"re_arm_rejected", wu.wu_id, {
|
|
4247
|
+
"reason": "spinning_reproduction_missing",
|
|
4248
|
+
"detail": repro_reason,
|
|
4249
|
+
})])
|
|
4250
|
+
commit_bookkeeping(
|
|
4251
|
+
[wu.file, events_path],
|
|
4252
|
+
f"chore(loop): {wu.wu_id} re-arm rejected "
|
|
4253
|
+
f"(spinning reproduction missing)"
|
|
4254
|
+
f"\n\nFeature: {wu.wu_id}",
|
|
4255
|
+
)
|
|
4256
|
+
print(f" RE-ARM REJECTED — {repro_reason}")
|
|
4257
|
+
blocked = True
|
|
4258
|
+
continue
|
|
4259
|
+
|
|
4058
4260
|
head_before = git("rev-parse", "HEAD")
|
|
4059
4261
|
# Snapshot the operator's untracked files alongside head_before:
|
|
4060
4262
|
# squash_commit must not absorb WIP that pre-dates this dispatch
|
|
@@ -4340,6 +4542,48 @@ def run(
|
|
|
4340
4542
|
f"{attempt}/{MAX_ATTEMPTS}"
|
|
4341
4543
|
)
|
|
4342
4544
|
continue
|
|
4545
|
+
# Produces-vs-diff cross-check (#198): the presence gate
|
|
4546
|
+
# above cannot see a WU that declared pre-existing files
|
|
4547
|
+
# it never touched (the FEAT-2026-0049/T06 done-without-
|
|
4548
|
+
# delivering shape). Every produces: entry must match a
|
|
4549
|
+
# path in this WU's squash diff; otherwise refuse the
|
|
4550
|
+
# pass, roll back the squash, retry within budget.
|
|
4551
|
+
prod_ok, prod_summary = assert_produces_in_diff(
|
|
4552
|
+
wu, touched,
|
|
4553
|
+
)
|
|
4554
|
+
if not prod_ok:
|
|
4555
|
+
reset_preserving_events(head_before, events_path,
|
|
4556
|
+
untracked_before=untracked_before)
|
|
4557
|
+
_prod_note = (
|
|
4558
|
+
prod_summary
|
|
4559
|
+
+ "\n\nEach listed path is a deliverable this WU "
|
|
4560
|
+
"declared in `produces:` but did not change. "
|
|
4561
|
+
"Make the declared change this attempt — do "
|
|
4562
|
+
"not declare done while a deliverable is "
|
|
4563
|
+
"untouched."
|
|
4564
|
+
)
|
|
4565
|
+
_prod_sig = ", ".join(sorted(
|
|
4566
|
+
Path(p).name for p in wu.produces
|
|
4567
|
+
if not any(t == str(p)
|
|
4568
|
+
or fnmatch.fnmatch(t, str(p))
|
|
4569
|
+
for t in touched)
|
|
4570
|
+
))[:100] or "produces_unchanged"
|
|
4571
|
+
wu_events.append(emit_attempt_outcome(
|
|
4572
|
+
wu, attempt, "produces_not_in_diff",
|
|
4573
|
+
attempts_usage[-1],
|
|
4574
|
+
failure_class="produces_not_in_diff",
|
|
4575
|
+
failure_signature=_prod_sig,
|
|
4576
|
+
failure_excerpt=extract_failure_excerpt(_prod_note),
|
|
4577
|
+
files_touched=touched,
|
|
4578
|
+
extras={"summary": prod_summary},
|
|
4579
|
+
))
|
|
4580
|
+
attempt_notes.append((attempt, _prod_note))
|
|
4581
|
+
failure_note = _prod_note
|
|
4582
|
+
print(
|
|
4583
|
+
f" PRODUCES NOT IN DIFF attempt "
|
|
4584
|
+
f"{attempt}/{MAX_ATTEMPTS} — {prod_summary}"
|
|
4585
|
+
)
|
|
4586
|
+
continue
|
|
4343
4587
|
if wu.type == "close":
|
|
4344
4588
|
# Re-read frontmatter post-squash: the agent writes
|
|
4345
4589
|
# `verdict:` to the WU file DURING dispatch, but
|
|
@@ -4355,14 +4599,19 @@ def run(
|
|
|
4355
4599
|
if verdict_permits_terminal_flips(wu.verdict):
|
|
4356
4600
|
close_wu_for_terminal = wu
|
|
4357
4601
|
else:
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4602
|
+
# Hedged verdict: revert EVERY terminal surface
|
|
4603
|
+
# the agent's close ceremony may have flipped,
|
|
4604
|
+
# not just PLAN.md (#195 — reverting one of two
|
|
4605
|
+
# surfaces left roadmap `done` vs PLAN `active`).
|
|
4606
|
+
reverted = revert_terminal_surfaces(
|
|
4607
|
+
wu, feature_dir, REPO_ROOT,
|
|
4608
|
+
)
|
|
4609
|
+
if reverted:
|
|
4362
4610
|
commit_bookkeeping(
|
|
4363
|
-
|
|
4364
|
-
f"chore(loop): {wu.wu_id} revert
|
|
4365
|
-
f" (hedged verdict)
|
|
4611
|
+
reverted,
|
|
4612
|
+
f"chore(loop): {wu.wu_id} revert terminal"
|
|
4613
|
+
f" surfaces (hedged verdict)"
|
|
4614
|
+
f"\n\nFeature: {wu.wu_id}",
|
|
4366
4615
|
)
|
|
4367
4616
|
elif _legacy_4wu_terminal_close_complete(
|
|
4368
4617
|
wu, units, gate, gates,
|
|
@@ -4401,9 +4650,38 @@ def run(
|
|
|
4401
4650
|
agent_status="complete",
|
|
4402
4651
|
agent_blocked_reason=None,
|
|
4403
4652
|
))
|
|
4653
|
+
# Lifetime fields (#199): a retrospective must be able
|
|
4654
|
+
# to compute rework-vs-new-work and planned-vs-actual
|
|
4655
|
+
# per WU from this one event, without reconciling
|
|
4656
|
+
# re-arm generations by hand. cumulative_* frontmatter
|
|
4657
|
+
# holds prior cycles' totals (folded at re-arm
|
|
4658
|
+
# dispatch); this cycle's numbers are added here.
|
|
4659
|
+
_fm_done, _ = read_frontmatter(wu.file)
|
|
4660
|
+
_prior_cost = _fm_done.get("cumulative_cost_usd")
|
|
4661
|
+
_prior_cost = (float(_prior_cost)
|
|
4662
|
+
if isinstance(_prior_cost, (int, float))
|
|
4663
|
+
and not isinstance(_prior_cost, bool)
|
|
4664
|
+
else 0.0)
|
|
4665
|
+
_prior_attempts = _fm_done.get("cumulative_attempts")
|
|
4666
|
+
_prior_attempts = (int(_prior_attempts)
|
|
4667
|
+
if isinstance(_prior_attempts, int)
|
|
4668
|
+
and not isinstance(_prior_attempts, bool)
|
|
4669
|
+
else 0)
|
|
4670
|
+
_planned = _fm_done.get("planned_cost_usd")
|
|
4671
|
+
_planned = (float(_planned)
|
|
4672
|
+
if isinstance(_planned, (int, float))
|
|
4673
|
+
and not isinstance(_planned, bool)
|
|
4674
|
+
else None)
|
|
4404
4675
|
wu_events.append(build_event("task_completed", wu.wu_id, {
|
|
4405
4676
|
"attempts": attempt,
|
|
4406
4677
|
"attempts_usage": attempts_usage,
|
|
4678
|
+
"type": wu.type,
|
|
4679
|
+
"re_arm_count": re_arm_count,
|
|
4680
|
+
"cost_usd": round(cum_usage["cost_usd"], 6),
|
|
4681
|
+
"cumulative_cost_usd": round(
|
|
4682
|
+
_prior_cost + cum_usage["cost_usd"], 6),
|
|
4683
|
+
"attempts_lifetime": _prior_attempts + attempt,
|
|
4684
|
+
"planned_cost_usd": _planned,
|
|
4407
4685
|
}))
|
|
4408
4686
|
flush_events(events_path, wu_events)
|
|
4409
4687
|
done_ids.add(wu.wu_id)
|
|
@@ -4508,6 +4786,15 @@ def run(
|
|
|
4508
4786
|
note_paths = persist_attempt_notes(
|
|
4509
4787
|
work_dir, wu.wu_id, attempt_notes)
|
|
4510
4788
|
backend.set_wu(wu, "status", "blocked_human")
|
|
4789
|
+
# Stamp the escalation identity into frontmatter (#200):
|
|
4790
|
+
# the re-arm reproduction gate needs the exact signature
|
|
4791
|
+
# at the NEXT dispatch, and events.jsonl is not read on
|
|
4792
|
+
# the dispatch path. Rides the same bookkeeping commit.
|
|
4793
|
+
backend.set_wu(wu, "escalation_reason",
|
|
4794
|
+
"spinning_signature_repeat")
|
|
4795
|
+
backend.set_wu(wu, "escalation_failure_class", _fc or "")
|
|
4796
|
+
backend.set_wu(wu, "escalation_failure_signature",
|
|
4797
|
+
_fs or "")
|
|
4511
4798
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
4512
4799
|
flush_events(events_path, wu_events)
|
|
4513
4800
|
commit_bookkeeping(
|
|
@@ -90,6 +90,7 @@ tests/test_leak_scan_content.py
|
|
|
90
90
|
tests/test_learnings_query.py
|
|
91
91
|
tests/test_legacy_4wu_terminal_flips.py
|
|
92
92
|
tests/test_lifecycle_integration.py
|
|
93
|
+
tests/test_lifetime_accounting.py
|
|
93
94
|
tests/test_lint_bare_produces_path.py
|
|
94
95
|
tests/test_lint_close_intermediate.py
|
|
95
96
|
tests/test_lint_close_wu.py
|
|
@@ -126,6 +127,7 @@ tests/test_planned_cost_lint.py
|
|
|
126
127
|
tests/test_prepare_feature.py
|
|
127
128
|
tests/test_prepare_scaffold_sync.py
|
|
128
129
|
tests/test_produces_field.py
|
|
130
|
+
tests/test_produces_in_diff.py
|
|
129
131
|
tests/test_redaction_windows_home.py
|
|
130
132
|
tests/test_resolve_base.py
|
|
131
133
|
tests/test_result_block.py
|
|
@@ -142,6 +144,7 @@ tests/test_scaffold_seed_sanity.py
|
|
|
142
144
|
tests/test_scaffold_upgrade.py
|
|
143
145
|
tests/test_scaffold_wiring.py
|
|
144
146
|
tests/test_skills_vendored_in_sync.py
|
|
147
|
+
tests/test_spinning_rearm_gate.py
|
|
145
148
|
tests/test_squash_commit_hook_crash.py
|
|
146
149
|
tests/test_template_closing_shapes.py
|
|
147
150
|
tests/test_terminal_flip_ownership.py
|
|
@@ -220,6 +220,35 @@ class TestParseGateFailureSignature(unittest.TestCase):
|
|
|
220
220
|
self.assertEqual(fc, "coverage")
|
|
221
221
|
self.assertEqual(sig, "file.py")
|
|
222
222
|
|
|
223
|
+
def test_tests_fail_extracts_surefire_failing_test(self):
|
|
224
|
+
"""#207: surefire failures name Class.method, not 'FAIL: test_*' —
|
|
225
|
+
the signature must carry the first failing test, not a generic
|
|
226
|
+
Maven line identical across distinct failures."""
|
|
227
|
+
stdout = (
|
|
228
|
+
"### tests: FAIL\n"
|
|
229
|
+
"[ERROR] Tests run: 431, Failures: 1, Errors: 0, Skipped: 0\n"
|
|
230
|
+
"[ERROR] RelationshipSymmetryTest.rejectsAsymmetric:123 "
|
|
231
|
+
"expected ERROR but was WARNING\n"
|
|
232
|
+
)
|
|
233
|
+
fc, sig = loop.parse_gate_failure_signature(stdout)
|
|
234
|
+
self.assertEqual(fc, "tests")
|
|
235
|
+
self.assertEqual(sig, "RelationshipSymmetryTest.rejectsAsymmetric")
|
|
236
|
+
|
|
237
|
+
def test_distinct_surefire_failures_yield_distinct_signatures(self):
|
|
238
|
+
base = "### tests: FAIL\n[ERROR] Tests run: 10, Failures: 1\n"
|
|
239
|
+
_, sig_a = loop.parse_gate_failure_signature(
|
|
240
|
+
base + "[ERROR] AlphaTest.one:1 boom\n")
|
|
241
|
+
_, sig_b = loop.parse_gate_failure_signature(
|
|
242
|
+
base + "[ERROR] BetaTest.two:2 boom\n")
|
|
243
|
+
self.assertNotEqual(sig_a, sig_b)
|
|
244
|
+
|
|
245
|
+
def test_pytest_pattern_still_wins_over_surefire(self):
|
|
246
|
+
stdout = ("### tests: FAIL\n"
|
|
247
|
+
"FAIL: test_foo\n"
|
|
248
|
+
"[ERROR] SomeTest.method:1 noise\n")
|
|
249
|
+
_, sig = loop.parse_gate_failure_signature(stdout)
|
|
250
|
+
self.assertEqual(sig, "test_foo")
|
|
251
|
+
|
|
223
252
|
def test_unknown_gate_name_returns_other(self):
|
|
224
253
|
stdout = "### custom-gate: FAIL\nsome output"
|
|
225
254
|
fc, _sig = loop.parse_gate_failure_signature(stdout)
|
|
@@ -304,6 +333,27 @@ class TestExtractFailureExcerpt(unittest.TestCase):
|
|
|
304
333
|
self.fail(f"UnicodeDecodeError raised: {e}")
|
|
305
334
|
self.assertIsInstance(excerpt, str)
|
|
306
335
|
|
|
336
|
+
def test_surefire_failing_tests_hoisted_to_front(self):
|
|
337
|
+
"""#207: Maven boilerplate [ERROR] lines crowd the head+tail budget;
|
|
338
|
+
the surefire failing-test lines must survive truncation by being
|
|
339
|
+
hoisted to the front of the relevant-line set."""
|
|
340
|
+
lines = ["[ERROR] Maven boilerplate wrapper line " + "x" * 40
|
|
341
|
+
for _ in range(200)]
|
|
342
|
+
# Failing tests buried mid-output — where the pre-#207 budget lost them.
|
|
343
|
+
lines.insert(100, "[ERROR] RelationshipSymmetryTest.rejectsAsymmetric:123 "
|
|
344
|
+
"expected ERROR but was WARNING")
|
|
345
|
+
lines.insert(101, "[ERROR] FkInferenceTest.retiresImplicit:45 "
|
|
346
|
+
"NullPointerException")
|
|
347
|
+
excerpt = loop.extract_failure_excerpt("\n".join(lines), max_chars=500)
|
|
348
|
+
self.assertIn("RelationshipSymmetryTest.rejectsAsymmetric", excerpt)
|
|
349
|
+
self.assertIn("FkInferenceTest.retiresImplicit", excerpt)
|
|
350
|
+
|
|
351
|
+
def test_no_surefire_lines_keeps_existing_order(self):
|
|
352
|
+
"""Without surefire lines the excerpt behaviour is unchanged."""
|
|
353
|
+
stdout = "Error: alpha\nError: beta"
|
|
354
|
+
excerpt = loop.extract_failure_excerpt(stdout)
|
|
355
|
+
self.assertEqual(excerpt, "Error: alpha\nError: beta")
|
|
356
|
+
|
|
307
357
|
def test_keeps_head_and_tail_on_truncation(self):
|
|
308
358
|
# #175: build-tool output puts the offending-file list up top and
|
|
309
359
|
# boilerplate (help URLs, stack hints) at the bottom. A tail-only
|