specfuse-loop 0.3.17__tar.gz → 0.3.19__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.17/specfuse_loop.egg-info → specfuse_loop-0.3.19}/PKG-INFO +1 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/pyproject.toml +1 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/_miniyaml.py +14 -7
- specfuse_loop-0.3.19/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/verification.yml.example +12 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/lint_plan.py +112 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/loop.py +153 -13
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/SOURCES.txt +1 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_attempt_outcome_emission.py +43 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_plan_errors.py +40 -0
- specfuse_loop-0.3.19/tests/test_lint_verification_reference.py +177 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_files_changed_guard.py +16 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_orchestration.py +67 -5
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_miniyaml_negative.py +10 -7
- specfuse_loop-0.3.17/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/LICENSE +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/NOTICE +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/README.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/setup.cfg +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/methodology.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_attempt_evidence.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.17 → specfuse_loop-0.3.19}/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.19"
|
|
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.19
|
|
@@ -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
|
|
|
@@ -40,6 +40,14 @@ VALID_TYPES = {"implementation", "retrospective", "lessons", "docs", "plan-next"
|
|
|
40
40
|
"close-intermediate"}
|
|
41
41
|
VALID_STATUS = {"draft", "pending", "ready", "in_progress", "in_review", "done",
|
|
42
42
|
"blocked_human", "abandoned"}
|
|
43
|
+
# Feature (PLAN.md) and gate status vocabularies the DRIVER branches on. Kept
|
|
44
|
+
# here (duplicated from loop.py's status literals, like VALID_TYPES/VALID_STATUS
|
|
45
|
+
# above) so the linter validates the same set the driver acts on — an
|
|
46
|
+
# unrecognized status that passes lint and then behaves differently at dispatch
|
|
47
|
+
# is the class #183 (deferred) and #185 exist to close. Keep in sync with
|
|
48
|
+
# find_feature / the gate-status transitions in loop.py.
|
|
49
|
+
VALID_FEATURE_STATUS = {"planned", "active", "deferred", "done", "abandoned"}
|
|
50
|
+
VALID_GATE_STATUS = {"open", "awaiting_review", "passed"}
|
|
43
51
|
CLOSING_SEQUENCE = ["retrospective", "lessons", "docs", "plan-next"]
|
|
44
52
|
# New compact closing shapes (FEAT-2026-0015):
|
|
45
53
|
# non-terminal gate: close-intermediate → plan-next
|
|
@@ -138,6 +146,69 @@ def _slice_section(body: str, section_name: str) -> str:
|
|
|
138
146
|
return after[:em.start()] if em else after
|
|
139
147
|
|
|
140
148
|
|
|
149
|
+
# Interpreters / super-generic words that don't distinguish one gate command
|
|
150
|
+
# from another — excluded from a command's signature tokens (#176).
|
|
151
|
+
_GATE_TOKEN_STOP = {
|
|
152
|
+
"python", "python3", "bash", "sh", "run", "exec", "discover",
|
|
153
|
+
"report", "source", "true", "false",
|
|
154
|
+
}
|
|
155
|
+
_GATE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.:+-]{2,}")
|
|
156
|
+
# Verification prose that delegates to the whole gate set — treat as
|
|
157
|
+
# referencing every gate so a legitimate "all gates pass" phrasing is not
|
|
158
|
+
# flagged.
|
|
159
|
+
_UMBRELLA_GATES_RE = re.compile(r"(?i)\b(all|every|entire|full|the)\b[^.\n]{0,24}\bgate")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _must_reference_gate_signatures(feature_dir: Path) -> list[tuple[str, set[str]]]:
|
|
163
|
+
"""Return [(gate_name, signature_tokens)] for OPT-IN must-reference gates (#176).
|
|
164
|
+
|
|
165
|
+
Reads ``<project>/.specfuse/verification.yml`` (``feature_dir`` is
|
|
166
|
+
``.../.specfuse/features/<name>``) and includes only `code` gates that set
|
|
167
|
+
``wu_must_reference: true``. This is deliberately opt-in: warning on every
|
|
168
|
+
declared gate is noise on real multi-gate projects (leak-scan, hygiene
|
|
169
|
+
bats, etc. that an individual WU has no business naming). A project flags
|
|
170
|
+
the gates whose fix is mechanical over a WU's own output — formatters,
|
|
171
|
+
auto-fixable linters — the class #175 shows can burn the whole attempt
|
|
172
|
+
budget when a WU forgets them. Empty list (the check no-ops) when the file
|
|
173
|
+
is absent, unparseable, or no gate opts in. Each gate's token set is the
|
|
174
|
+
distinctive words of its command plus the gate name, lowercased.
|
|
175
|
+
"""
|
|
176
|
+
vpath = feature_dir.parent.parent / "verification.yml"
|
|
177
|
+
if not vpath.is_file():
|
|
178
|
+
return []
|
|
179
|
+
try:
|
|
180
|
+
cfg = _miniyaml.parse(vpath.read_text()) or {}
|
|
181
|
+
except _miniyaml.MiniYAMLError:
|
|
182
|
+
return []
|
|
183
|
+
out: list[tuple[str, set[str]]] = []
|
|
184
|
+
for gate in cfg.get("code") or []:
|
|
185
|
+
if not isinstance(gate, dict) or gate.get("wu_must_reference") is not True:
|
|
186
|
+
continue
|
|
187
|
+
name = str(gate.get("name") or "").strip()
|
|
188
|
+
command = str(gate.get("command") or "")
|
|
189
|
+
tokens = {t.lower() for t in _GATE_TOKEN_RE.findall(command)
|
|
190
|
+
if t.lower() not in _GATE_TOKEN_STOP}
|
|
191
|
+
if name:
|
|
192
|
+
tokens.add(name.lower())
|
|
193
|
+
if tokens:
|
|
194
|
+
out.append((name or command[:40], tokens))
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _unreferenced_code_gates(
|
|
199
|
+
verification_text: str, signatures: list[tuple[str, set[str]]],
|
|
200
|
+
) -> list[str]:
|
|
201
|
+
"""Names of code gates whose tokens *verification_text* references none of.
|
|
202
|
+
|
|
203
|
+
An umbrella "all gates" phrasing short-circuits to [] (author delegated).
|
|
204
|
+
"""
|
|
205
|
+
if _UMBRELLA_GATES_RE.search(verification_text):
|
|
206
|
+
return []
|
|
207
|
+
low = verification_text.lower()
|
|
208
|
+
return [name for name, tokens in signatures
|
|
209
|
+
if not any(tok in low for tok in tokens)]
|
|
210
|
+
|
|
211
|
+
|
|
141
212
|
def detect_oracle_verbs(ac_section_text: str) -> list[str]:
|
|
142
213
|
"""Return matched oracle-verb strings found in the AC section text."""
|
|
143
214
|
found = []
|
|
@@ -249,6 +320,22 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
249
320
|
if missing:
|
|
250
321
|
errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
|
|
251
322
|
|
|
323
|
+
# Feature status must be a value the driver recognizes (#185). An
|
|
324
|
+
# unrecognized status passes every other check and then behaves
|
|
325
|
+
# differently at dispatch — the shape #183 (deferred passed lint,
|
|
326
|
+
# dispatched anyway) exposed. Only validate when present; the missing-key
|
|
327
|
+
# check above already covers absence.
|
|
328
|
+
if "status" in fm and fm["status"] not in VALID_FEATURE_STATUS:
|
|
329
|
+
errs.append(
|
|
330
|
+
f"PLAN.md frontmatter status {fm['status']!r} is not a recognized "
|
|
331
|
+
f"feature status {sorted(VALID_FEATURE_STATUS)}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Opt-in must-reference gate signatures, loaded once for the
|
|
335
|
+
# Verification-reference WARN below (#176). Empty unless the project flags
|
|
336
|
+
# gates with `wu_must_reference: true`.
|
|
337
|
+
must_ref_gate_sigs = _must_reference_gate_signatures(feature_dir)
|
|
338
|
+
|
|
252
339
|
if "base" in fm:
|
|
253
340
|
base_val = fm["base"]
|
|
254
341
|
feature_id_val = fm.get("feature_id", feature_dir.name)
|
|
@@ -283,6 +370,12 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
283
370
|
gate_path = feature_dir / gate_file_rel
|
|
284
371
|
if gate_path.exists():
|
|
285
372
|
gfm, _ = read_frontmatter(gate_path)
|
|
373
|
+
# Gate status must be a value the driver recognizes (#185).
|
|
374
|
+
if "status" in gfm and gfm["status"] not in VALID_GATE_STATUS:
|
|
375
|
+
errs.append(
|
|
376
|
+
f"{gate_file_rel}: gate status {gfm['status']!r} is not "
|
|
377
|
+
f"a recognized gate status {sorted(VALID_GATE_STATUS)}"
|
|
378
|
+
)
|
|
286
379
|
if "cost_budget_usd" in gfm:
|
|
287
380
|
val = gfm["cost_budget_usd"]
|
|
288
381
|
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
|
@@ -400,6 +493,25 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
400
493
|
f"driver. See authoring-work-units §9 + FEAT-2026-0017."
|
|
401
494
|
)
|
|
402
495
|
|
|
496
|
+
# Verification ↔ must-reference gate WARN (#176). For gates a
|
|
497
|
+
# project has flagged `wu_must_reference: true` — those whose fix is
|
|
498
|
+
# mechanical over a WU's own output (formatters, auto-fixable
|
|
499
|
+
# linters) — the driver enforces them regardless of what a WU's
|
|
500
|
+
# Verification names. A session not told to run one writes code that
|
|
501
|
+
# fails it and, per #175, can burn the whole attempt budget on the
|
|
502
|
+
# mechanical failure. Flag at lint time, at zero model cost. Opt-in,
|
|
503
|
+
# so no noise on projects that don't flag any gate.
|
|
504
|
+
if wu_type_val == "implementation" and must_ref_gate_sigs:
|
|
505
|
+
verif_text = _slice_section(wbody, "Verification")
|
|
506
|
+
unref = _unreferenced_code_gates(verif_text, must_ref_gate_sigs)
|
|
507
|
+
if unref:
|
|
508
|
+
print(
|
|
509
|
+
f"WARN: {wfile}: Verification references none of the "
|
|
510
|
+
f"must-reference gate(s): {', '.join(unref)}. The driver "
|
|
511
|
+
f"runs them regardless; name each so the session knows to "
|
|
512
|
+
f"satisfy it before finishing (#176)."
|
|
513
|
+
)
|
|
514
|
+
|
|
403
515
|
# Deliverable-presence declaration WARN (FEAT-2026-0022/T01).
|
|
404
516
|
# Advisory: an implementation WU should declare the file path(s) it
|
|
405
517
|
# 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.19"
|
|
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
|
|
|
@@ -253,15 +266,41 @@ def write_frontmatter_field(path: Path, key: str, value) -> None:
|
|
|
253
266
|
def find_feature(arg: str | None) -> Path:
|
|
254
267
|
if arg:
|
|
255
268
|
d = FEATURES_DIR / arg if not arg.startswith(".") else Path(arg)
|
|
256
|
-
|
|
269
|
+
plan = d / "PLAN.md"
|
|
270
|
+
if not plan.exists():
|
|
257
271
|
sys.exit(f"No PLAN.md under {d}")
|
|
272
|
+
# A `deferred` feature is parked — non-dispatchable by contract (the
|
|
273
|
+
# gate-status skill documents it as "non-dispatchable but resumable;
|
|
274
|
+
# the loop does not auto-resume it"). Refuse an explicit --feature
|
|
275
|
+
# targeting one instead of dispatching it anyway (#183). Malformed
|
|
276
|
+
# frontmatter is left to fail later where the graph is parsed.
|
|
277
|
+
try:
|
|
278
|
+
_fm, _ = read_frontmatter(plan)
|
|
279
|
+
except _miniyaml.MiniYAMLError:
|
|
280
|
+
_fm = {}
|
|
281
|
+
if _fm.get("status") == "deferred":
|
|
282
|
+
sys.exit(
|
|
283
|
+
f"{d.name} is deferred (parked) — the loop does not dispatch "
|
|
284
|
+
f"deferred features. Flip its PLAN.md status back to `active` "
|
|
285
|
+
f"when the blocker clears, then re-run."
|
|
286
|
+
)
|
|
258
287
|
return d
|
|
259
288
|
actives = []
|
|
260
289
|
done_pending_wrap = []
|
|
290
|
+
deferred = []
|
|
261
291
|
for d in sorted(FEATURES_DIR.glob("*/")):
|
|
262
292
|
plan = d / "PLAN.md"
|
|
263
293
|
if plan.exists():
|
|
264
|
-
|
|
294
|
+
try:
|
|
295
|
+
fm, _ = read_frontmatter(plan)
|
|
296
|
+
except _miniyaml.MiniYAMLError as exc:
|
|
297
|
+
# One feature's malformed frontmatter must not block dispatch of
|
|
298
|
+
# every OTHER feature. Feature lookup is the earliest and least
|
|
299
|
+
# relevant failure point; skip the bad candidate with a warning
|
|
300
|
+
# rather than aborting the whole scan (#177).
|
|
301
|
+
print(f"warning: skipping {d.name} — unparseable PLAN.md "
|
|
302
|
+
f"frontmatter: {exc}", file=sys.stderr)
|
|
303
|
+
continue
|
|
265
304
|
if fm.get("status") == "active":
|
|
266
305
|
actives.append(d)
|
|
267
306
|
elif fm.get("status") == "done":
|
|
@@ -271,10 +310,23 @@ def find_feature(arg: str | None) -> Path:
|
|
|
271
310
|
# push + PR are pending.
|
|
272
311
|
if (d / "RETROSPECTIVE.md").is_file():
|
|
273
312
|
done_pending_wrap.append(d)
|
|
313
|
+
elif fm.get("status") == "deferred":
|
|
314
|
+
# Parked — non-dispatchable (#183). Never enters `actives`, so
|
|
315
|
+
# a bare invocation won't pick it up; surfaced below so a lone
|
|
316
|
+
# deferred feature reads as parked, not silently absent.
|
|
317
|
+
deferred.append(d)
|
|
274
318
|
if len(actives) == 1:
|
|
275
319
|
return actives[0]
|
|
276
320
|
if not actives:
|
|
277
321
|
msg = "No active feature. Set a feature's PLAN.md status to 'active'.\n"
|
|
322
|
+
if deferred:
|
|
323
|
+
names = ", ".join(d.name for d in deferred[-3:])
|
|
324
|
+
msg += (
|
|
325
|
+
f" - {len(deferred)} deferred (parked) feature(s), skipped: "
|
|
326
|
+
f"{names}\n"
|
|
327
|
+
f" Flip one's PLAN.md status to `active` to "
|
|
328
|
+
f"resume it.\n"
|
|
329
|
+
)
|
|
278
330
|
if done_pending_wrap:
|
|
279
331
|
names = ", ".join(d.name for d in done_pending_wrap[-3:])
|
|
280
332
|
msg += (
|
|
@@ -655,7 +707,56 @@ def extract_failure_excerpt(stdout: str, max_chars: int = 500) -> str:
|
|
|
655
707
|
encoded = text.encode("utf-8")
|
|
656
708
|
if len(encoded) <= max_chars:
|
|
657
709
|
return text
|
|
658
|
-
|
|
710
|
+
# Head+tail, not tail-only (#175): build-tool output puts the actionable
|
|
711
|
+
# part (the list of offending files) up top and boilerplate (help URLs,
|
|
712
|
+
# stack-trace hints) at the bottom. A tail-only slice reliably keeps the
|
|
713
|
+
# noise; splitting the budget preserves both ends.
|
|
714
|
+
marker = "\n...\n"
|
|
715
|
+
budget = max_chars - len(marker.encode("utf-8"))
|
|
716
|
+
if budget <= 0:
|
|
717
|
+
return encoded[-max_chars:].decode("utf-8", errors="ignore")
|
|
718
|
+
head_bytes = budget // 2
|
|
719
|
+
tail_bytes = budget - head_bytes
|
|
720
|
+
head = encoded[:head_bytes].decode("utf-8", errors="ignore")
|
|
721
|
+
tail = encoded[-tail_bytes:].decode("utf-8", errors="ignore")
|
|
722
|
+
return head + marker + tail
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# Forward-looking retry directives keyed by failure_class (#175 fix 1). The
|
|
726
|
+
# gate output replayed into a retry describes files the per-attempt reset
|
|
727
|
+
# already discarded, and reads as stale advice about a prior attempt. These
|
|
728
|
+
# reframe it as a standing requirement for the fresh attempt and name the
|
|
729
|
+
# mechanical remedy per class — no per-tool knowledge.
|
|
730
|
+
_RETRY_CLASS_HINT: dict[str, str] = {
|
|
731
|
+
"lint": "This was a lint/format gate — its fix is mechanical: run the "
|
|
732
|
+
"formatter / linter's fix command over your changes and re-run the "
|
|
733
|
+
"gate before declaring done, rather than only rewriting the code.",
|
|
734
|
+
"tests": "Run the failing tests and make them pass before declaring done.",
|
|
735
|
+
"coverage": "Add tests covering the new or changed code to clear the "
|
|
736
|
+
"coverage gate before declaring done.",
|
|
737
|
+
"security": "Resolve the flagged security findings (or add a narrowly "
|
|
738
|
+
"scoped, justified suppression) before declaring done.",
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def synthesize_retry_directive(failure_class: "str | None") -> str:
|
|
743
|
+
"""Return a forward-looking retry instruction for *failure_class* (#175).
|
|
744
|
+
|
|
745
|
+
Prepended to the failure note fed into the next attempt. The note itself
|
|
746
|
+
is raw gate output about files the reset discarded; without this lead a
|
|
747
|
+
fresh session reads it as stale advice and reproduces the same omission
|
|
748
|
+
(e.g. never running the formatter), failing identically until attempts
|
|
749
|
+
exhaust. The lead reframes the output as a standing requirement and the
|
|
750
|
+
per-class hint names the remedy generically.
|
|
751
|
+
"""
|
|
752
|
+
lead = ("The previous attempt's work was DISCARDED before this retry, so "
|
|
753
|
+
"the gate output below refers to files that no longer exist. Do "
|
|
754
|
+
"not read it as stale advice about a past attempt — treat it as a "
|
|
755
|
+
"standing requirement for THIS fresh attempt. ")
|
|
756
|
+
hint = _RETRY_CLASS_HINT.get(
|
|
757
|
+
failure_class or "",
|
|
758
|
+
"Satisfy every declared gate before declaring done.")
|
|
759
|
+
return lead + hint
|
|
659
760
|
|
|
660
761
|
|
|
661
762
|
def emit_attempt_outcome(
|
|
@@ -1846,6 +1947,22 @@ def verify_files_changed(result: dict, head_before: str) -> list[str]:
|
|
|
1846
1947
|
return unchanged
|
|
1847
1948
|
|
|
1848
1949
|
|
|
1950
|
+
def _annotate_unchanged_paths(paths: "list[str]") -> str:
|
|
1951
|
+
"""Format files_changed-mismatch paths, distinguishing missing from unchanged.
|
|
1952
|
+
|
|
1953
|
+
Each declared-but-non-diffing path is labeled ``(missing)`` when it does
|
|
1954
|
+
not exist on disk or ``(no diff)`` when it exists but is byte-identical to
|
|
1955
|
+
head_before. Prior wording listed paths bare, which conflated the two —
|
|
1956
|
+
an existing, unchanged file could read as a deletion claim and seed a
|
|
1957
|
+
wrong root-cause diagnosis (#182). The labels say exactly what was found.
|
|
1958
|
+
"""
|
|
1959
|
+
lines = []
|
|
1960
|
+
for p in paths:
|
|
1961
|
+
label = "no diff" if Path(str(p)).exists() else "missing"
|
|
1962
|
+
lines.append(f" - ({label}) {p}")
|
|
1963
|
+
return "\n".join(lines)
|
|
1964
|
+
|
|
1965
|
+
|
|
1849
1966
|
# Smoke-import runner (FEAT-2026-0008/T03). The conservative pattern matches
|
|
1850
1967
|
# ONLY a `python3 -c "from X import Y"` line. The agent-authored WU body may
|
|
1851
1968
|
# declare an existence check naming new symbols this WU just minted; the
|
|
@@ -4203,17 +4320,33 @@ def run(
|
|
|
4203
4320
|
# against head_before. Treat as a failed attempt: skip
|
|
4204
4321
|
# squash, reset the tree, record evidence, retry within
|
|
4205
4322
|
# budget. payload is the list of unchanged paths.
|
|
4323
|
+
_mm_paths = list(payload)
|
|
4206
4324
|
note = (
|
|
4207
|
-
"RESULT block declared `files_changed`
|
|
4208
|
-
"
|
|
4209
|
-
|
|
4210
|
-
+
|
|
4211
|
-
|
|
4325
|
+
"The RESULT block declared these `files_changed` "
|
|
4326
|
+
"paths, but each shows NO diff against HEAD before "
|
|
4327
|
+
"this attempt:\n"
|
|
4328
|
+
+ _annotate_unchanged_paths(_mm_paths)
|
|
4329
|
+
+ "\n\nDeclaring an unchanged path is what fails this "
|
|
4330
|
+
"guard. For each path: if it did NOT need changing, "
|
|
4331
|
+
"OMIT it from `files_changed` (declare only files you "
|
|
4332
|
+
"actually edit); if it SHOULD have changed, make the "
|
|
4333
|
+
"edit this attempt. A `(missing)` path was never "
|
|
4334
|
+
"created; a `(no diff)` path exists but is unchanged."
|
|
4212
4335
|
)
|
|
4336
|
+
# Populate failure_class/signature/excerpt (#182): a
|
|
4337
|
+
# driver-side rejection previously emitted all-null, so
|
|
4338
|
+
# events.jsonl — and gate-status, which reads only it —
|
|
4339
|
+
# showed empty columns exactly where the diagnosis lives.
|
|
4340
|
+
_mm_sig = ", ".join(
|
|
4341
|
+
sorted({Path(str(p)).name for p in _mm_paths})
|
|
4342
|
+
)[:100] or "unchanged"
|
|
4213
4343
|
wu_events.append(emit_attempt_outcome(
|
|
4214
4344
|
wu, attempt, "files_changed_mismatch",
|
|
4215
4345
|
attempts_usage[-1],
|
|
4216
|
-
|
|
4346
|
+
failure_class="files_changed_mismatch",
|
|
4347
|
+
failure_signature=_mm_sig,
|
|
4348
|
+
failure_excerpt=extract_failure_excerpt(note),
|
|
4349
|
+
extras={"unchanged_paths": _mm_paths},
|
|
4217
4350
|
))
|
|
4218
4351
|
attempt_notes.append((attempt, note))
|
|
4219
4352
|
failure_note = note
|
|
@@ -4241,9 +4374,16 @@ def run(
|
|
|
4241
4374
|
+ "```diff\n" + _diff + "\n```\n"
|
|
4242
4375
|
)
|
|
4243
4376
|
attempt_notes.append((attempt, _evidence))
|
|
4244
|
-
failure_note = payload
|
|
4245
4377
|
_fc, _fs = parse_gate_failure_signature(payload)
|
|
4246
4378
|
_ex = extract_failure_excerpt(payload)
|
|
4379
|
+
# Prepend a forward-looking directive so the retry reads the
|
|
4380
|
+
# gate output as a standing requirement, not stale advice
|
|
4381
|
+
# about the discarded attempt (#175 fix 1).
|
|
4382
|
+
failure_note = (
|
|
4383
|
+
synthesize_retry_directive(_fc)
|
|
4384
|
+
+ "\n\n## Gate output from the discarded attempt\n\n"
|
|
4385
|
+
+ payload
|
|
4386
|
+
)
|
|
4247
4387
|
wu_events.append(emit_attempt_outcome(
|
|
4248
4388
|
wu, attempt, "failed",
|
|
4249
4389
|
attempts_usage[-1],
|
|
@@ -102,6 +102,7 @@ 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
|
|
105
106
|
tests/test_loop_attempt_evidence.py
|
|
106
107
|
tests/test_loop_auto_archive.py
|
|
107
108
|
tests/test_loop_caveman_preamble.py
|
|
@@ -304,6 +304,49 @@ class TestExtractFailureExcerpt(unittest.TestCase):
|
|
|
304
304
|
self.fail(f"UnicodeDecodeError raised: {e}")
|
|
305
305
|
self.assertIsInstance(excerpt, str)
|
|
306
306
|
|
|
307
|
+
def test_keeps_head_and_tail_on_truncation(self):
|
|
308
|
+
# #175: build-tool output puts the offending-file list up top and
|
|
309
|
+
# boilerplate (help URLs, stack hints) at the bottom. A tail-only
|
|
310
|
+
# slice keeps the noise; head+tail must preserve both ends.
|
|
311
|
+
lines = ["Error: HEAD_MARKER offending file list here"]
|
|
312
|
+
lines += [f"Error: filler line {i} " + "x" * 40 for i in range(400)]
|
|
313
|
+
lines += ["Error: TAIL_MARKER re-run with -X for debug"]
|
|
314
|
+
excerpt = loop.extract_failure_excerpt("\n".join(lines), max_chars=500)
|
|
315
|
+
self.assertLessEqual(len(excerpt.encode("utf-8")), 500)
|
|
316
|
+
self.assertIn("HEAD_MARKER", excerpt)
|
|
317
|
+
self.assertIn("TAIL_MARKER", excerpt)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class TestSynthesizeRetryDirective(unittest.TestCase):
|
|
321
|
+
"""#175 fix 1: the retry note must carry a forward-looking lesson, not
|
|
322
|
+
just replay output about files the reset already discarded."""
|
|
323
|
+
|
|
324
|
+
def test_lint_directive_is_forward_looking(self):
|
|
325
|
+
d = loop.synthesize_retry_directive("lint")
|
|
326
|
+
self.assertIn("discard", d.lower())
|
|
327
|
+
# Names the mechanical remedy without per-tool knowledge.
|
|
328
|
+
self.assertIn("format", d.lower())
|
|
329
|
+
self.assertIn("before", d.lower())
|
|
330
|
+
|
|
331
|
+
def test_tests_directive_mentions_tests(self):
|
|
332
|
+
d = loop.synthesize_retry_directive("tests")
|
|
333
|
+
self.assertIn("test", d.lower())
|
|
334
|
+
self.assertIn("before", d.lower())
|
|
335
|
+
|
|
336
|
+
def test_coverage_and_security_have_specific_hints(self):
|
|
337
|
+
self.assertIn("coverage", loop.synthesize_retry_directive("coverage").lower())
|
|
338
|
+
self.assertIn(
|
|
339
|
+
"finding", loop.synthesize_retry_directive("security").lower())
|
|
340
|
+
|
|
341
|
+
def test_unknown_class_falls_back_to_generic_non_empty(self):
|
|
342
|
+
d = loop.synthesize_retry_directive("other")
|
|
343
|
+
self.assertTrue(d.strip())
|
|
344
|
+
self.assertIn("discard", d.lower())
|
|
345
|
+
|
|
346
|
+
def test_none_class_does_not_crash(self):
|
|
347
|
+
d = loop.synthesize_retry_directive(None)
|
|
348
|
+
self.assertTrue(d.strip())
|
|
349
|
+
|
|
307
350
|
|
|
308
351
|
# --------------------------------------------------------------------------- #
|
|
309
352
|
# TestCumulativeFoldOnRearm #
|
|
@@ -104,6 +104,46 @@ class TestLintErrorArms(unittest.TestCase):
|
|
|
104
104
|
self.assertTrue(any("frontmatter missing keys" in e for e in errs),
|
|
105
105
|
f"expected missing-keys error; errs={errs}")
|
|
106
106
|
|
|
107
|
+
def test_unknown_feature_status_errors(self):
|
|
108
|
+
"""#185: a feature status outside the driver's enum is a lint error."""
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
110
|
+
feat = _build_minimal_feature(Path(tmp))
|
|
111
|
+
plan = feat / "PLAN.md"
|
|
112
|
+
plan.write_text(plan.read_text().replace(
|
|
113
|
+
"status: active", "status: paused", 1))
|
|
114
|
+
errs = lint_plan.lint(feat)
|
|
115
|
+
self.assertTrue(
|
|
116
|
+
any("'paused'" in e and "feature status" in e for e in errs),
|
|
117
|
+
f"expected feature-status error; errs={errs}")
|
|
118
|
+
|
|
119
|
+
def test_known_feature_statuses_do_not_error(self):
|
|
120
|
+
"""Every documented feature status passes the vocabulary check."""
|
|
121
|
+
for status in ("planned", "active", "deferred", "done", "abandoned"):
|
|
122
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
123
|
+
feat = _build_minimal_feature(Path(tmp))
|
|
124
|
+
plan = feat / "PLAN.md"
|
|
125
|
+
plan.write_text(plan.read_text().replace(
|
|
126
|
+
"status: active", f"status: {status}", 1))
|
|
127
|
+
errs = lint_plan.lint(feat)
|
|
128
|
+
self.assertFalse(
|
|
129
|
+
any("feature status" in e for e in errs),
|
|
130
|
+
f"{status!r} should be a valid feature status; errs={errs}")
|
|
131
|
+
|
|
132
|
+
def test_unknown_gate_status_errors(self):
|
|
133
|
+
"""#185: a gate status outside the driver's enum is a lint error."""
|
|
134
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
135
|
+
feat = _build_minimal_feature(Path(tmp))
|
|
136
|
+
(feat / "GATE-01.md").write_text(
|
|
137
|
+
"---\nstatus: reviewing\n---\n\n# Gate 1\n")
|
|
138
|
+
# Point the graph's gate at the file so lint reads it.
|
|
139
|
+
plan = feat / "PLAN.md"
|
|
140
|
+
plan.write_text(plan.read_text().replace(
|
|
141
|
+
" - gate: 1\n", " - gate: 1\n file: GATE-01.md\n", 1))
|
|
142
|
+
errs = lint_plan.lint(feat)
|
|
143
|
+
self.assertTrue(
|
|
144
|
+
any("'reviewing'" in e and "gate status" in e for e in errs),
|
|
145
|
+
f"expected gate-status error; errs={errs}")
|
|
146
|
+
|
|
107
147
|
def test_plan_no_yaml_block(self):
|
|
108
148
|
"""Line 91: PLAN.md has valid frontmatter but no fenced yaml graph block."""
|
|
109
149
|
with tempfile.TemporaryDirectory() as tmp:
|