specfuse-loop 0.3.16__tar.gz → 0.3.18__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.16/specfuse_loop.egg-info → specfuse_loop-0.3.18}/PKG-INFO +1 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/pyproject.toml +1 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/_miniyaml.py +14 -7
- specfuse_loop-0.3.18/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/verification.yml.example +12 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/lint_plan.py +87 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/loop.py +194 -18
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/scaffold.py +37 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/SOURCES.txt +2 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_attempt_outcome_emission.py +125 -0
- specfuse_loop-0.3.18/tests/test_lint_verification_reference.py +177 -0
- specfuse_loop-0.3.18/tests/test_loop_attempt_evidence.py +116 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_orchestration.py +35 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_miniyaml_negative.py +10 -7
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_upgrade.py +66 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_untracked_file_absorption.py +5 -0
- specfuse_loop-0.3.16/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/LICENSE +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/NOTICE +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/README.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/setup.cfg +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.16 → specfuse_loop-0.3.18}/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.18"
|
|
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"
|
|
@@ -53,7 +53,9 @@ on a missing gate set — silent misparses are worse than crashes.
|
|
|
53
53
|
fields that the driver writes to WU frontmatter.
|
|
54
54
|
- **booleans** — exact lowercase `true` / `false` only. `True`, `yes`,
|
|
55
55
|
`on`, etc. are errors.
|
|
56
|
-
- **empty value** — a key followed by `:` with nothing
|
|
56
|
+
- **empty value / explicit null** — a key followed by `:` with nothing
|
|
57
|
+
after is `None`; so are the explicit YAML null spellings `null`,
|
|
58
|
+
`Null`, `NULL`, and `~` (matching yaml.safe_load).
|
|
57
59
|
* **Inline (flow) lists** — `[]`, `[a]`, `[a, b]`. Items are scalars only;
|
|
58
60
|
nested brackets are an error. Real usage is correlation-ID lists.
|
|
59
61
|
* **Comments** — `#` starting a line (after optional whitespace), or
|
|
@@ -73,8 +75,9 @@ on a missing gate set — silent misparses are worse than crashes.
|
|
|
73
75
|
* Mixed / inconsistent indentation.
|
|
74
76
|
* Nested brackets inside a flow list.
|
|
75
77
|
* Escape sequences other than `\\` and `\"` in double-quoted strings.
|
|
76
|
-
* Boolean spellings other than exact lowercase `true` / `false
|
|
77
|
-
|
|
78
|
+
* Boolean spellings other than exact lowercase `true` / `false` (the YAML
|
|
79
|
+
1.1 "Norway problem" — `yes`/`no`/`on`/`off`/`True` — stays rejected on
|
|
80
|
+
purpose; only `null` is accepted as a non-lowercase-keyword idiom).
|
|
78
81
|
* Integers with sign, leading zeros, floats, hex, or underscores.
|
|
79
82
|
|
|
80
83
|
A file using any of those raises `MiniYAMLError` with a line number and a
|
|
@@ -108,7 +111,7 @@ _FORBIDDEN_BOOLS = {
|
|
|
108
111
|
"yes", "Yes", "YES", "no", "No", "NO",
|
|
109
112
|
"on", "On", "ON", "off", "Off", "OFF",
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
_NULL_SCALARS = {"null", "Null", "NULL", "~"}
|
|
112
115
|
|
|
113
116
|
|
|
114
117
|
# --------------------------------------------------------------------------- #
|
|
@@ -363,9 +366,13 @@ def _parse_scalar(text: str, lineno: int) -> Any:
|
|
|
363
366
|
# forgiving result-block parse path on real agent output.
|
|
364
367
|
raise MiniYAMLError(
|
|
365
368
|
f"line {lineno}: flow mappings unsupported — use a block mapping")
|
|
366
|
-
if text in
|
|
367
|
-
|
|
368
|
-
|
|
369
|
+
if text in _NULL_SCALARS:
|
|
370
|
+
# Explicit YAML null spellings map to None, same as an empty value.
|
|
371
|
+
# Accepting them (rather than failing loud) removes a foot-gun for
|
|
372
|
+
# hand-authored frontmatter and for anything generated by a real YAML
|
|
373
|
+
# library, which emits `null` for None — and matches yaml.safe_load,
|
|
374
|
+
# tightening the equivalence guarantee (#177).
|
|
375
|
+
return None
|
|
369
376
|
if text in _FORBIDDEN_BOOLS:
|
|
370
377
|
raise MiniYAMLError(
|
|
371
378
|
f"line {lineno}: only lowercase `true`/`false` accepted as "
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.18
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
#
|
|
29
29
|
# cost_tracking: true
|
|
30
30
|
|
|
31
|
+
# PER-GATE `wu_must_reference: true` (optional, default false). Flag a gate
|
|
32
|
+
# whose fix is MECHANICAL over a work unit's own output — a formatter, an
|
|
33
|
+
# auto-fixable linter (`spotless:check`, `black --check`, `gofmt -l`,
|
|
34
|
+
# `prettier --check`). The plan linter (specfuse-lint) then WARNs when an
|
|
35
|
+
# implementation WU's Verification section names none of that gate's command —
|
|
36
|
+
# catching, at authoring time and zero model cost, the case where a WU forgets
|
|
37
|
+
# a mechanical gate and the driver spins retries on the identical failure until
|
|
38
|
+
# the attempt budget is exhausted. Leave it off for project-global gates
|
|
39
|
+
# (leak-scan, hygiene checks) that an individual WU has no reason to name.
|
|
31
40
|
code:
|
|
32
41
|
- name: tests
|
|
33
42
|
command: "pytest -q"
|
|
@@ -37,6 +46,9 @@ code:
|
|
|
37
46
|
command: "python3 -W error -c 'pass'" # replace with your build's -Werror equivalent
|
|
38
47
|
- name: lint
|
|
39
48
|
command: "ruff check ."
|
|
49
|
+
- name: format
|
|
50
|
+
command: "black --check ." # deterministic formatter: fix with `black .`
|
|
51
|
+
wu_must_reference: true # WUs must name it (see note above)
|
|
40
52
|
- name: security
|
|
41
53
|
command: "bandit -r src -ll" # or your OWASP-aligned scanner
|
|
42
54
|
|
|
@@ -138,6 +138,69 @@ def _slice_section(body: str, section_name: str) -> str:
|
|
|
138
138
|
return after[:em.start()] if em else after
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
# Interpreters / super-generic words that don't distinguish one gate command
|
|
142
|
+
# from another — excluded from a command's signature tokens (#176).
|
|
143
|
+
_GATE_TOKEN_STOP = {
|
|
144
|
+
"python", "python3", "bash", "sh", "run", "exec", "discover",
|
|
145
|
+
"report", "source", "true", "false",
|
|
146
|
+
}
|
|
147
|
+
_GATE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.:+-]{2,}")
|
|
148
|
+
# Verification prose that delegates to the whole gate set — treat as
|
|
149
|
+
# referencing every gate so a legitimate "all gates pass" phrasing is not
|
|
150
|
+
# flagged.
|
|
151
|
+
_UMBRELLA_GATES_RE = re.compile(r"(?i)\b(all|every|entire|full|the)\b[^.\n]{0,24}\bgate")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _must_reference_gate_signatures(feature_dir: Path) -> list[tuple[str, set[str]]]:
|
|
155
|
+
"""Return [(gate_name, signature_tokens)] for OPT-IN must-reference gates (#176).
|
|
156
|
+
|
|
157
|
+
Reads ``<project>/.specfuse/verification.yml`` (``feature_dir`` is
|
|
158
|
+
``.../.specfuse/features/<name>``) and includes only `code` gates that set
|
|
159
|
+
``wu_must_reference: true``. This is deliberately opt-in: warning on every
|
|
160
|
+
declared gate is noise on real multi-gate projects (leak-scan, hygiene
|
|
161
|
+
bats, etc. that an individual WU has no business naming). A project flags
|
|
162
|
+
the gates whose fix is mechanical over a WU's own output — formatters,
|
|
163
|
+
auto-fixable linters — the class #175 shows can burn the whole attempt
|
|
164
|
+
budget when a WU forgets them. Empty list (the check no-ops) when the file
|
|
165
|
+
is absent, unparseable, or no gate opts in. Each gate's token set is the
|
|
166
|
+
distinctive words of its command plus the gate name, lowercased.
|
|
167
|
+
"""
|
|
168
|
+
vpath = feature_dir.parent.parent / "verification.yml"
|
|
169
|
+
if not vpath.is_file():
|
|
170
|
+
return []
|
|
171
|
+
try:
|
|
172
|
+
cfg = _miniyaml.parse(vpath.read_text()) or {}
|
|
173
|
+
except _miniyaml.MiniYAMLError:
|
|
174
|
+
return []
|
|
175
|
+
out: list[tuple[str, set[str]]] = []
|
|
176
|
+
for gate in cfg.get("code") or []:
|
|
177
|
+
if not isinstance(gate, dict) or gate.get("wu_must_reference") is not True:
|
|
178
|
+
continue
|
|
179
|
+
name = str(gate.get("name") or "").strip()
|
|
180
|
+
command = str(gate.get("command") or "")
|
|
181
|
+
tokens = {t.lower() for t in _GATE_TOKEN_RE.findall(command)
|
|
182
|
+
if t.lower() not in _GATE_TOKEN_STOP}
|
|
183
|
+
if name:
|
|
184
|
+
tokens.add(name.lower())
|
|
185
|
+
if tokens:
|
|
186
|
+
out.append((name or command[:40], tokens))
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _unreferenced_code_gates(
|
|
191
|
+
verification_text: str, signatures: list[tuple[str, set[str]]],
|
|
192
|
+
) -> list[str]:
|
|
193
|
+
"""Names of code gates whose tokens *verification_text* references none of.
|
|
194
|
+
|
|
195
|
+
An umbrella "all gates" phrasing short-circuits to [] (author delegated).
|
|
196
|
+
"""
|
|
197
|
+
if _UMBRELLA_GATES_RE.search(verification_text):
|
|
198
|
+
return []
|
|
199
|
+
low = verification_text.lower()
|
|
200
|
+
return [name for name, tokens in signatures
|
|
201
|
+
if not any(tok in low for tok in tokens)]
|
|
202
|
+
|
|
203
|
+
|
|
141
204
|
def detect_oracle_verbs(ac_section_text: str) -> list[str]:
|
|
142
205
|
"""Return matched oracle-verb strings found in the AC section text."""
|
|
143
206
|
found = []
|
|
@@ -249,6 +312,11 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
249
312
|
if missing:
|
|
250
313
|
errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
|
|
251
314
|
|
|
315
|
+
# Opt-in must-reference gate signatures, loaded once for the
|
|
316
|
+
# Verification-reference WARN below (#176). Empty unless the project flags
|
|
317
|
+
# gates with `wu_must_reference: true`.
|
|
318
|
+
must_ref_gate_sigs = _must_reference_gate_signatures(feature_dir)
|
|
319
|
+
|
|
252
320
|
if "base" in fm:
|
|
253
321
|
base_val = fm["base"]
|
|
254
322
|
feature_id_val = fm.get("feature_id", feature_dir.name)
|
|
@@ -400,6 +468,25 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
400
468
|
f"driver. See authoring-work-units §9 + FEAT-2026-0017."
|
|
401
469
|
)
|
|
402
470
|
|
|
471
|
+
# Verification ↔ must-reference gate WARN (#176). For gates a
|
|
472
|
+
# project has flagged `wu_must_reference: true` — those whose fix is
|
|
473
|
+
# mechanical over a WU's own output (formatters, auto-fixable
|
|
474
|
+
# linters) — the driver enforces them regardless of what a WU's
|
|
475
|
+
# Verification names. A session not told to run one writes code that
|
|
476
|
+
# fails it and, per #175, can burn the whole attempt budget on the
|
|
477
|
+
# mechanical failure. Flag at lint time, at zero model cost. Opt-in,
|
|
478
|
+
# so no noise on projects that don't flag any gate.
|
|
479
|
+
if wu_type_val == "implementation" and must_ref_gate_sigs:
|
|
480
|
+
verif_text = _slice_section(wbody, "Verification")
|
|
481
|
+
unref = _unreferenced_code_gates(verif_text, must_ref_gate_sigs)
|
|
482
|
+
if unref:
|
|
483
|
+
print(
|
|
484
|
+
f"WARN: {wfile}: Verification references none of the "
|
|
485
|
+
f"must-reference gate(s): {', '.join(unref)}. The driver "
|
|
486
|
+
f"runs them regardless; name each so the session knows to "
|
|
487
|
+
f"satisfy it before finishing (#176)."
|
|
488
|
+
)
|
|
489
|
+
|
|
403
490
|
# Deliverable-presence declaration WARN (FEAT-2026-0022/T01).
|
|
404
491
|
# Advisory: an implementation WU should declare the file path(s) it
|
|
405
492
|
# is contracted to yield via `produces:`, which T02's presence gate
|
|
@@ -63,7 +63,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
63
63
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
64
64
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
65
65
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
66
|
-
DRIVER_VERSION = "0.3.
|
|
66
|
+
DRIVER_VERSION = "0.3.18"
|
|
67
67
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
68
68
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
69
69
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -190,14 +190,27 @@ FM = re.compile(r"^---\s*$")
|
|
|
190
190
|
|
|
191
191
|
|
|
192
192
|
def read_frontmatter(path: Path) -> tuple[dict, str]:
|
|
193
|
-
"""Return (frontmatter_dict, body_text).
|
|
193
|
+
"""Return (frontmatter_dict, body_text).
|
|
194
|
+
|
|
195
|
+
A parse failure is re-raised with the file path attached and the line
|
|
196
|
+
clarified as frontmatter-relative (add 1 for the file line, since the
|
|
197
|
+
opening `---` is line 1). Without this a `_miniyaml.MiniYAMLError` carries
|
|
198
|
+
only a block-relative line number, so locating the offending file across
|
|
199
|
+
many feature folders meant grepping by hand (#177).
|
|
200
|
+
"""
|
|
194
201
|
lines = path.read_text().splitlines()
|
|
195
202
|
if not lines or not FM.match(lines[0]):
|
|
196
203
|
return {}, path.read_text()
|
|
197
204
|
j = 1
|
|
198
205
|
while j < len(lines) and not FM.match(lines[j]):
|
|
199
206
|
j += 1
|
|
200
|
-
|
|
207
|
+
try:
|
|
208
|
+
fm = _miniyaml.parse("\n".join(lines[1:j])) or {}
|
|
209
|
+
except _miniyaml.MiniYAMLError as exc:
|
|
210
|
+
raise _miniyaml.MiniYAMLError(
|
|
211
|
+
f"{path}: {exc} (line is relative to the frontmatter block; "
|
|
212
|
+
f"add 1 for the file line)"
|
|
213
|
+
) from exc
|
|
201
214
|
body = "\n".join(lines[j + 1 :])
|
|
202
215
|
return fm, body
|
|
203
216
|
|
|
@@ -261,7 +274,16 @@ def find_feature(arg: str | None) -> Path:
|
|
|
261
274
|
for d in sorted(FEATURES_DIR.glob("*/")):
|
|
262
275
|
plan = d / "PLAN.md"
|
|
263
276
|
if plan.exists():
|
|
264
|
-
|
|
277
|
+
try:
|
|
278
|
+
fm, _ = read_frontmatter(plan)
|
|
279
|
+
except _miniyaml.MiniYAMLError as exc:
|
|
280
|
+
# One feature's malformed frontmatter must not block dispatch of
|
|
281
|
+
# every OTHER feature. Feature lookup is the earliest and least
|
|
282
|
+
# relevant failure point; skip the bad candidate with a warning
|
|
283
|
+
# rather than aborting the whole scan (#177).
|
|
284
|
+
print(f"warning: skipping {d.name} — unparseable PLAN.md "
|
|
285
|
+
f"frontmatter: {exc}", file=sys.stderr)
|
|
286
|
+
continue
|
|
265
287
|
if fm.get("status") == "active":
|
|
266
288
|
actives.append(d)
|
|
267
289
|
elif fm.get("status") == "done":
|
|
@@ -542,6 +564,36 @@ def detect_degraded_oracle(stdout: str) -> str | None:
|
|
|
542
564
|
return None
|
|
543
565
|
|
|
544
566
|
|
|
567
|
+
# A failure signature carries no distinguishing content when it is a bare
|
|
568
|
+
# code/markdown fence, whitespace-only, or pure ANSI. Non-Python gate output
|
|
569
|
+
# (e.g. a TypeScript `tests` gate) matches none of the Python-specific
|
|
570
|
+
# _SIG_PATTERNS below and used to fall through to the first line after the
|
|
571
|
+
# marker — often a ``` fence — collapsing genuinely different failures to one
|
|
572
|
+
# value and false-firing spinning_signature_repeat (#167).
|
|
573
|
+
_FENCE_RE = re.compile(r"^[`~]{3,}[\w.+-]*$")
|
|
574
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
575
|
+
# Sentinel returned when every line after the FAIL marker is non-informative.
|
|
576
|
+
# Distinct from `no_gate_marker` (a marker WAS found) and treated as
|
|
577
|
+
# non-informative by `_is_noninformative_signature`.
|
|
578
|
+
NO_SIGNATURE = "no_signature"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _is_noninformative_signature(sig: "str | None") -> bool:
|
|
582
|
+
"""True when *sig* cannot distinguish one failure from another.
|
|
583
|
+
|
|
584
|
+
Covers None, whitespace-only, bare fences (``` / ~~~, optionally with a
|
|
585
|
+
language tag), pure-ANSI strings, and the NO_SIGNATURE sentinel. The spin
|
|
586
|
+
detector and the extraction fallback both consult this so a content-free
|
|
587
|
+
signature neither becomes a WU's identity nor trips escalation (#167).
|
|
588
|
+
"""
|
|
589
|
+
if sig is None or sig == NO_SIGNATURE:
|
|
590
|
+
return True
|
|
591
|
+
stripped = _ANSI_RE.sub("", sig).strip()
|
|
592
|
+
if not stripped:
|
|
593
|
+
return True
|
|
594
|
+
return bool(_FENCE_RE.match(stripped))
|
|
595
|
+
|
|
596
|
+
|
|
545
597
|
def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
|
|
546
598
|
"""Extract (failure_class, failure_signature) from gate runner stdout.
|
|
547
599
|
|
|
@@ -577,9 +629,12 @@ def parse_gate_failure_signature(stdout: str) -> tuple[str, str]:
|
|
|
577
629
|
return failure_class, sig if sig else "unknown"
|
|
578
630
|
for line in after_lines:
|
|
579
631
|
stripped = line.strip()
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
632
|
+
# Skip non-informative lines (bare fences, whitespace, pure ANSI):
|
|
633
|
+
# keying the signature off one would collapse distinct failures (#167).
|
|
634
|
+
if _is_noninformative_signature(stripped):
|
|
635
|
+
continue
|
|
636
|
+
return failure_class, stripped[:100]
|
|
637
|
+
return failure_class, NO_SIGNATURE
|
|
583
638
|
|
|
584
639
|
|
|
585
640
|
def detect_spinning_signature_repeat(
|
|
@@ -592,6 +647,9 @@ def detect_spinning_signature_repeat(
|
|
|
592
647
|
Returns False when either element of current is None.
|
|
593
648
|
Returns False when current or prior is the no_gate_marker sentinel to
|
|
594
649
|
avoid false-positive halts on parser-opaque failures (AC4).
|
|
650
|
+
Returns False when either signature is non-informative — a bare fence,
|
|
651
|
+
whitespace, pure ANSI, or the NO_SIGNATURE sentinel — since such values
|
|
652
|
+
collapse distinct failures and would false-fire the spin halt (#167).
|
|
595
653
|
"""
|
|
596
654
|
_SENTINEL = ("other", "no_gate_marker")
|
|
597
655
|
if prior is None:
|
|
@@ -600,6 +658,9 @@ def detect_spinning_signature_repeat(
|
|
|
600
658
|
return False
|
|
601
659
|
if current == _SENTINEL or prior == _SENTINEL:
|
|
602
660
|
return False
|
|
661
|
+
if (_is_noninformative_signature(current[1])
|
|
662
|
+
or _is_noninformative_signature(prior[1])):
|
|
663
|
+
return False
|
|
603
664
|
return current == prior
|
|
604
665
|
|
|
605
666
|
|
|
@@ -616,7 +677,56 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
|
|
|
616
677
|
encoded = text.encode("utf-8")
|
|
617
678
|
if len(encoded) <= max_chars:
|
|
618
679
|
return text
|
|
619
|
-
|
|
680
|
+
# Head+tail, not tail-only (#175): build-tool output puts the actionable
|
|
681
|
+
# part (the list of offending files) up top and boilerplate (help URLs,
|
|
682
|
+
# stack-trace hints) at the bottom. A tail-only slice reliably keeps the
|
|
683
|
+
# noise; splitting the budget preserves both ends.
|
|
684
|
+
marker = "\n...\n"
|
|
685
|
+
budget = max_chars - len(marker.encode("utf-8"))
|
|
686
|
+
if budget <= 0:
|
|
687
|
+
return encoded[-max_chars:].decode("utf-8", errors="ignore")
|
|
688
|
+
head_bytes = budget // 2
|
|
689
|
+
tail_bytes = budget - head_bytes
|
|
690
|
+
head = encoded[:head_bytes].decode("utf-8", errors="ignore")
|
|
691
|
+
tail = encoded[-tail_bytes:].decode("utf-8", errors="ignore")
|
|
692
|
+
return head + marker + tail
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# Forward-looking retry directives keyed by failure_class (#175 fix 1). The
|
|
696
|
+
# gate output replayed into a retry describes files the per-attempt reset
|
|
697
|
+
# already discarded, and reads as stale advice about a prior attempt. These
|
|
698
|
+
# reframe it as a standing requirement for the fresh attempt and name the
|
|
699
|
+
# mechanical remedy per class — no per-tool knowledge.
|
|
700
|
+
_RETRY_CLASS_HINT: dict[str, str] = {
|
|
701
|
+
"lint": "This was a lint/format gate — its fix is mechanical: run the "
|
|
702
|
+
"formatter / linter's fix command over your changes and re-run the "
|
|
703
|
+
"gate before declaring done, rather than only rewriting the code.",
|
|
704
|
+
"tests": "Run the failing tests and make them pass before declaring done.",
|
|
705
|
+
"coverage": "Add tests covering the new or changed code to clear the "
|
|
706
|
+
"coverage gate before declaring done.",
|
|
707
|
+
"security": "Resolve the flagged security findings (or add a narrowly "
|
|
708
|
+
"scoped, justified suppression) before declaring done.",
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def synthesize_retry_directive(failure_class: "str | None") -> str:
|
|
713
|
+
"""Return a forward-looking retry instruction for *failure_class* (#175).
|
|
714
|
+
|
|
715
|
+
Prepended to the failure note fed into the next attempt. The note itself
|
|
716
|
+
is raw gate output about files the reset discarded; without this lead a
|
|
717
|
+
fresh session reads it as stale advice and reproduces the same omission
|
|
718
|
+
(e.g. never running the formatter), failing identically until attempts
|
|
719
|
+
exhaust. The lead reframes the output as a standing requirement and the
|
|
720
|
+
per-class hint names the remedy generically.
|
|
721
|
+
"""
|
|
722
|
+
lead = ("The previous attempt's work was DISCARDED before this retry, so "
|
|
723
|
+
"the gate output below refers to files that no longer exist. Do "
|
|
724
|
+
"not read it as stale advice about a past attempt — treat it as a "
|
|
725
|
+
"standing requirement for THIS fresh attempt. ")
|
|
726
|
+
hint = _RETRY_CLASS_HINT.get(
|
|
727
|
+
failure_class or "",
|
|
728
|
+
"Satisfy every declared gate before declaring done.")
|
|
729
|
+
return lead + hint
|
|
620
730
|
|
|
621
731
|
|
|
622
732
|
def emit_attempt_outcome(
|
|
@@ -1498,6 +1608,51 @@ def reset_preserving_events(
|
|
|
1498
1608
|
_clean_attempt_untracked(untracked_before, events_path)
|
|
1499
1609
|
|
|
1500
1610
|
|
|
1611
|
+
def capture_working_tree_diff(head_before: str, max_chars: int = 20000) -> str:
|
|
1612
|
+
"""Return `git diff <head_before>` (working tree vs pre-attempt base), capped.
|
|
1613
|
+
|
|
1614
|
+
Folded into a failing attempt's evidence note BEFORE the per-attempt
|
|
1615
|
+
`git reset --hard` discards the rejected work, so a blocked WU stays
|
|
1616
|
+
diagnosable — a human (or /gate-status) can see the diff that was rolled
|
|
1617
|
+
back, not just a truncated failure excerpt (#168).
|
|
1618
|
+
|
|
1619
|
+
Best-effort: returns "" on any git error or a clean tree. Truncated to
|
|
1620
|
+
max_chars so a large refactor's diff cannot bloat the committed note.
|
|
1621
|
+
"""
|
|
1622
|
+
try:
|
|
1623
|
+
out = subprocess.run(
|
|
1624
|
+
["git", "diff", head_before],
|
|
1625
|
+
capture_output=True, text=True, check=True,
|
|
1626
|
+
).stdout
|
|
1627
|
+
except (subprocess.CalledProcessError, OSError):
|
|
1628
|
+
return ""
|
|
1629
|
+
if len(out) > max_chars:
|
|
1630
|
+
out = out[:max_chars] + "\n… (diff truncated)\n"
|
|
1631
|
+
return out
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def persist_attempt_notes(
|
|
1635
|
+
work_dir: Path, wu_id: str, attempt_notes: "list[tuple[int, str]]",
|
|
1636
|
+
) -> "list[Path]":
|
|
1637
|
+
"""Write buffered per-attempt evidence to work/<wu>/attempt-N.md.
|
|
1638
|
+
|
|
1639
|
+
Extracted so both escalation paths persist evidence the same way: the
|
|
1640
|
+
exhausted-attempts `for-else` and the early `spinning_signature_repeat`
|
|
1641
|
+
halt. Previously only the former wrote notes, so a signature-repeat block
|
|
1642
|
+
left nothing on disk to diagnose (#168). Returns the written paths for
|
|
1643
|
+
inclusion in the escalation commit. `work/` is gitignored, so the explicit
|
|
1644
|
+
`git add` in commit_bookkeeping is what tracks these.
|
|
1645
|
+
"""
|
|
1646
|
+
wu_key = wu_id.replace("/", "_")
|
|
1647
|
+
paths: list[Path] = []
|
|
1648
|
+
for atmpt, evidence in attempt_notes:
|
|
1649
|
+
p = work_dir / wu_key / f"attempt-{atmpt}.md"
|
|
1650
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1651
|
+
p.write_text(evidence)
|
|
1652
|
+
paths.append(p)
|
|
1653
|
+
return paths
|
|
1654
|
+
|
|
1655
|
+
|
|
1501
1656
|
class SquashCommitError(RuntimeError):
|
|
1502
1657
|
"""Raised when squash_commit's `git commit` is rejected (non-zero exit).
|
|
1503
1658
|
|
|
@@ -4144,10 +4299,29 @@ def run(
|
|
|
4144
4299
|
# Per-attempt notes are buffered (not written to disk) so they
|
|
4145
4300
|
# ride with the spinning-escalation commit if we exhaust
|
|
4146
4301
|
# attempts; on eventual PASS they're discarded as scratch.
|
|
4147
|
-
|
|
4148
|
-
|
|
4302
|
+
# Fold the rejected working-tree diff into the buffered
|
|
4303
|
+
# evidence NOW, before the reset below discards it (#168).
|
|
4304
|
+
# Signature/excerpt parsing stays on the raw payload.
|
|
4305
|
+
_diff = capture_working_tree_diff(head_before)
|
|
4306
|
+
_evidence = payload
|
|
4307
|
+
if _diff:
|
|
4308
|
+
_evidence = (
|
|
4309
|
+
payload
|
|
4310
|
+
+ "\n\n## Rejected working-tree diff "
|
|
4311
|
+
"(discarded by git reset on block)\n\n"
|
|
4312
|
+
+ "```diff\n" + _diff + "\n```\n"
|
|
4313
|
+
)
|
|
4314
|
+
attempt_notes.append((attempt, _evidence))
|
|
4149
4315
|
_fc, _fs = parse_gate_failure_signature(payload)
|
|
4150
4316
|
_ex = extract_failure_excerpt(payload)
|
|
4317
|
+
# Prepend a forward-looking directive so the retry reads the
|
|
4318
|
+
# gate output as a standing requirement, not stale advice
|
|
4319
|
+
# about the discarded attempt (#175 fix 1).
|
|
4320
|
+
failure_note = (
|
|
4321
|
+
synthesize_retry_directive(_fc)
|
|
4322
|
+
+ "\n\n## Gate output from the discarded attempt\n\n"
|
|
4323
|
+
+ payload
|
|
4324
|
+
)
|
|
4151
4325
|
wu_events.append(emit_attempt_outcome(
|
|
4152
4326
|
wu, attempt, "failed",
|
|
4153
4327
|
attempts_usage[-1],
|
|
@@ -4169,11 +4343,18 @@ def run(
|
|
|
4169
4343
|
}))
|
|
4170
4344
|
reset_preserving_events(head_before, events_path,
|
|
4171
4345
|
untracked_before=untracked_before)
|
|
4346
|
+
# Persist buffered evidence (verify output + rejected
|
|
4347
|
+
# diff) AFTER the reset — work/ is gitignored so the
|
|
4348
|
+
# reset leaves it untouched, and this makes a
|
|
4349
|
+
# signature-repeat block diagnosable like the
|
|
4350
|
+
# exhausted-attempts path (#168).
|
|
4351
|
+
note_paths = persist_attempt_notes(
|
|
4352
|
+
work_dir, wu.wu_id, attempt_notes)
|
|
4172
4353
|
backend.set_wu(wu, "status", "blocked_human")
|
|
4173
4354
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
4174
4355
|
flush_events(events_path, wu_events)
|
|
4175
4356
|
commit_bookkeeping(
|
|
4176
|
-
[wu.file, events_path],
|
|
4357
|
+
[wu.file, events_path, *note_paths],
|
|
4177
4358
|
f"chore(loop): {wu.wu_id} blocked_human "
|
|
4178
4359
|
f"(spinning_signature_repeat, attempt {attempt})"
|
|
4179
4360
|
f"\n\nFeature: {wu.wu_id}",
|
|
@@ -4206,13 +4387,8 @@ def run(
|
|
|
4206
4387
|
o == "zero_token" for o in attempt_outcomes)
|
|
4207
4388
|
reason = ("all_attempts_zero_token" if all_zero
|
|
4208
4389
|
else "spinning_detected")
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
for atmpt, evidence in attempt_notes:
|
|
4212
|
-
p = work_dir / wu_key / f"attempt-{atmpt}.md"
|
|
4213
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
4214
|
-
p.write_text(evidence)
|
|
4215
|
-
note_paths.append(p)
|
|
4390
|
+
note_paths = persist_attempt_notes(
|
|
4391
|
+
work_dir, wu.wu_id, attempt_notes)
|
|
4216
4392
|
backend.set_wu(wu, "status", "blocked_human")
|
|
4217
4393
|
write_cost_to_wu(backend, wu, cum_usage)
|
|
4218
4394
|
wu_events.append(build_event("human_escalation", wu.wu_id, {
|
|
@@ -8,6 +8,7 @@ import importlib.resources
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
import sys
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
from . import _miniyaml
|
|
@@ -389,9 +390,45 @@ def upgrade_specfuse(
|
|
|
389
390
|
wire_claude(target_path)
|
|
390
391
|
_write_manifest(specfuse_dir, manifest_entries)
|
|
391
392
|
|
|
393
|
+
_warn_unmanaged_legacy_dirs(specfuse_dir)
|
|
394
|
+
|
|
392
395
|
return sorted(written)
|
|
393
396
|
|
|
394
397
|
|
|
398
|
+
def _warn_unmanaged_legacy_dirs(specfuse_dir: Path) -> None:
|
|
399
|
+
"""Warn (stderr) when legacy scripts/ or skills/ copies exist post-upgrade.
|
|
400
|
+
|
|
401
|
+
Current scaffold data ships neither directory: scripts come from the
|
|
402
|
+
specfuse-loop package, skills from the specfuse@specfuse plugin. Only
|
|
403
|
+
pre-PyPI (init.sh-era) repos carry full copies, and `upgrade` overlays
|
|
404
|
+
none of them while still advancing VERSION — so a VERSION that outpaces
|
|
405
|
+
the vendored scripts/skills is silent drift (#165). New projects have
|
|
406
|
+
neither dir, so this is a no-op for them.
|
|
407
|
+
|
|
408
|
+
A warning, not a prune: reconciling those copies (see `migrate_legacy`)
|
|
409
|
+
is destructive and left to an explicit operator step.
|
|
410
|
+
"""
|
|
411
|
+
drift: list[str] = []
|
|
412
|
+
for name in ("scripts", "skills"):
|
|
413
|
+
d = specfuse_dir / name
|
|
414
|
+
if d.is_dir():
|
|
415
|
+
n = sum(1 for p in d.rglob("*") if p.is_file())
|
|
416
|
+
if n:
|
|
417
|
+
drift.append(f" .specfuse/{name}/ ({n} file(s))")
|
|
418
|
+
if not drift:
|
|
419
|
+
return
|
|
420
|
+
print(
|
|
421
|
+
f"specfuse upgrade: WARNING — VERSION advanced to {scaffold_version()}, "
|
|
422
|
+
"but these directories are NOT managed by upgrade and may be stale:\n"
|
|
423
|
+
+ "\n".join(drift)
|
|
424
|
+
+ "\n Scripts are provided by the specfuse-loop package and skills by "
|
|
425
|
+
"the specfuse@specfuse plugin; these are leftover full copies from an "
|
|
426
|
+
"older (pre-package) install. Remove them once nothing local depends "
|
|
427
|
+
"on them.",
|
|
428
|
+
file=sys.stderr,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
395
432
|
def init(target: str | Path, *, ci_check: str | None = None) -> list[str]:
|
|
396
433
|
"""Bootstrap a Specfuse-enabled repo: write .specfuse/ then wire .claude/.
|
|
397
434
|
|
|
@@ -102,6 +102,8 @@ tests/test_lint_plan_relative_import.py
|
|
|
102
102
|
tests/test_lint_produces_driver_helper.py
|
|
103
103
|
tests/test_lint_sections.py
|
|
104
104
|
tests/test_lint_task_graph_yaml_selection.py
|
|
105
|
+
tests/test_lint_verification_reference.py
|
|
106
|
+
tests/test_loop_attempt_evidence.py
|
|
105
107
|
tests/test_loop_auto_archive.py
|
|
106
108
|
tests/test_loop_caveman_preamble.py
|
|
107
109
|
tests/test_loop_close_intermediate.py
|