specfuse-loop 0.3.18__tar.gz → 0.3.20__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.18/specfuse_loop.egg-info → specfuse_loop-0.3.20}/PKG-INFO +1 -1
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/pyproject.toml +1 -1
- specfuse_loop-0.3.20/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/methodology.md +7 -1
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/lint_plan.py +25 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/loop.py +165 -8
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval_intermediate_wiring.py +11 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval_terminal_wiring.py +111 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_plan_errors.py +40 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_files_changed_guard.py +16 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_orchestration.py +32 -5
- specfuse_loop-0.3.18/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/LICENSE +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/NOTICE +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/README.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/setup.cfg +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/templates/WU.template.md +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/scaffold.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/SOURCES.txt +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_init_integration.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_close_intermediate.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_lint_verification_reference.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_attempt_evidence.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_gate_budget.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_data_in_sync.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_docs.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_init.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_resources.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_upgrade.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_upgrade_integration.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/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.20"
|
|
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.20
|
|
@@ -139,7 +139,13 @@ auto-close only eliminates the reflective overhead, not the forward-design step.
|
|
|
139
139
|
full close ceremony regardless of predicate outcome. Alternatively, set
|
|
140
140
|
`auto_close_disabled: true` in a feature's `PLAN.md` frontmatter to disable
|
|
141
141
|
auto-close permanently for that feature (e.g., for features that are inherently
|
|
142
|
-
exploratory and expect off-plan behavior).
|
|
142
|
+
exploratory and expect off-plan behavior). For a single **substantive close**
|
|
143
|
+
— a `close` (or `close-intermediate`) WU whose acceptance criteria assert on the
|
|
144
|
+
source tree, reconcile prior gates, or observe runtime behaviour rather than
|
|
145
|
+
just writing the retrospective stub — set `auto_close_disabled: true` in **that
|
|
146
|
+
WU's** frontmatter. The predicate keys on the implementation WUs' cost and
|
|
147
|
+
plan-conformance and cannot see what a close verifies, so a substantive close
|
|
148
|
+
must be marked must-run or its verification is silently skipped (#189).
|
|
143
149
|
|
|
144
150
|
**Predicate-version transparency** — every `auto_close_decision` event in
|
|
145
151
|
`events.jsonl` carries a `predicate_version` field (e.g., `predicate_version:
|
|
@@ -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
|
|
@@ -312,6 +320,17 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
312
320
|
if missing:
|
|
313
321
|
errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
|
|
314
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
|
+
|
|
315
334
|
# Opt-in must-reference gate signatures, loaded once for the
|
|
316
335
|
# Verification-reference WARN below (#176). Empty unless the project flags
|
|
317
336
|
# gates with `wu_must_reference: true`.
|
|
@@ -351,6 +370,12 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
351
370
|
gate_path = feature_dir / gate_file_rel
|
|
352
371
|
if gate_path.exists():
|
|
353
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
|
+
)
|
|
354
379
|
if "cost_budget_usd" in gfm:
|
|
355
380
|
val = gfm["cost_budget_usd"]
|
|
356
381
|
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
|
@@ -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.20"
|
|
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
|
|
@@ -266,11 +266,28 @@ def write_frontmatter_field(path: Path, key: str, value) -> None:
|
|
|
266
266
|
def find_feature(arg: str | None) -> Path:
|
|
267
267
|
if arg:
|
|
268
268
|
d = FEATURES_DIR / arg if not arg.startswith(".") else Path(arg)
|
|
269
|
-
|
|
269
|
+
plan = d / "PLAN.md"
|
|
270
|
+
if not plan.exists():
|
|
270
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
|
+
)
|
|
271
287
|
return d
|
|
272
288
|
actives = []
|
|
273
289
|
done_pending_wrap = []
|
|
290
|
+
deferred = []
|
|
274
291
|
for d in sorted(FEATURES_DIR.glob("*/")):
|
|
275
292
|
plan = d / "PLAN.md"
|
|
276
293
|
if plan.exists():
|
|
@@ -293,10 +310,23 @@ def find_feature(arg: str | None) -> Path:
|
|
|
293
310
|
# push + PR are pending.
|
|
294
311
|
if (d / "RETROSPECTIVE.md").is_file():
|
|
295
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)
|
|
296
318
|
if len(actives) == 1:
|
|
297
319
|
return actives[0]
|
|
298
320
|
if not actives:
|
|
299
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
|
+
)
|
|
300
330
|
if done_pending_wrap:
|
|
301
331
|
names = ", ".join(d.name for d in done_pending_wrap[-3:])
|
|
302
332
|
msg += (
|
|
@@ -1917,6 +1947,22 @@ def verify_files_changed(result: dict, head_before: str) -> list[str]:
|
|
|
1917
1947
|
return unchanged
|
|
1918
1948
|
|
|
1919
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
|
+
|
|
1920
1966
|
# Smoke-import runner (FEAT-2026-0008/T03). The conservative pattern matches
|
|
1921
1967
|
# ONLY a `python3 -c "from X import Y"` line. The agent-authored WU body may
|
|
1922
1968
|
# declare an existence check naming new symbols this WU just minted; the
|
|
@@ -2732,6 +2778,56 @@ def _already_auto_closed(wu_file: Path) -> bool:
|
|
|
2732
2778
|
return auto in (True, "true", "True")
|
|
2733
2779
|
|
|
2734
2780
|
|
|
2781
|
+
def _gate_impl_deliverables_present(
|
|
2782
|
+
feature_dir: Path, gate: "GateNode",
|
|
2783
|
+
) -> tuple[bool, str]:
|
|
2784
|
+
"""Return (True, "") iff every implementation WU in *gate* has its declared
|
|
2785
|
+
``produces:`` deliverables present and non-empty on disk (#190).
|
|
2786
|
+
|
|
2787
|
+
Auto-close keys on WU cost/plan-conformance and trusts each WU's ``done``
|
|
2788
|
+
status; ``assert_declared_deliverables`` — the dispatch-time presence gate —
|
|
2789
|
+
never runs on an auto-closed gate, so a WU that reported ``done`` with its
|
|
2790
|
+
``produces:`` paths absent still counts as a clean gate. Running the same
|
|
2791
|
+
check here stops the predicate auto-closing over a ``done`` that did not
|
|
2792
|
+
deliver. Non-implementation WUs and WUs with no ``produces:`` are skipped
|
|
2793
|
+
(the latter is assert_declared_deliverables's own opt-out). A missing or
|
|
2794
|
+
malformed WU file is left to the predicate / dispatch path that already
|
|
2795
|
+
handles it, not crashed on here.
|
|
2796
|
+
"""
|
|
2797
|
+
for ref in gate.refs:
|
|
2798
|
+
try:
|
|
2799
|
+
wu = load_wu(feature_dir, ref)
|
|
2800
|
+
except (FileNotFoundError, KeyError, _miniyaml.MiniYAMLError):
|
|
2801
|
+
continue
|
|
2802
|
+
if wu.type != "implementation":
|
|
2803
|
+
continue
|
|
2804
|
+
ok, summary = assert_declared_deliverables(wu)
|
|
2805
|
+
if not ok:
|
|
2806
|
+
return False, f"{ref.get('id', wu.wu_id)}: {summary}"
|
|
2807
|
+
return True, ""
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
def _close_wu_disables_auto_close(close_wu: "WorkUnit | None") -> bool:
|
|
2811
|
+
"""True iff the close WU's frontmatter sets ``auto_close_disabled`` truthy.
|
|
2812
|
+
|
|
2813
|
+
Per-WU counterpart to PLAN.md's feature-level ``auto_close_disabled``
|
|
2814
|
+
(#189). The auto-close predicate keys on the implementation WUs' cost and
|
|
2815
|
+
plan-conformance; it has no signal for what the CLOSE WU itself is
|
|
2816
|
+
contracted to verify. A substantive close — one whose acceptance criteria
|
|
2817
|
+
assert on the source tree, reconcile prior gates, or observe runtime
|
|
2818
|
+
behaviour — must be dispatched and run, not optimized away. The plan author
|
|
2819
|
+
marks such a close with ``auto_close_disabled: true`` in its frontmatter;
|
|
2820
|
+
the predicate then always defers to dispatch for that gate.
|
|
2821
|
+
|
|
2822
|
+
Distinct from the ``auto_close`` key, which is the auto-close OUTPUT marker
|
|
2823
|
+
(set to ``true`` by mark_close_wu_auto_closed after a gate auto-closes).
|
|
2824
|
+
"""
|
|
2825
|
+
if close_wu is None or not close_wu.file.is_file():
|
|
2826
|
+
return False
|
|
2827
|
+
fm, _ = read_frontmatter(close_wu.file)
|
|
2828
|
+
return fm.get("auto_close_disabled") in (True, "true", "True")
|
|
2829
|
+
|
|
2830
|
+
|
|
2735
2831
|
def write_stub_retrospective_terminal(
|
|
2736
2832
|
feature_dir: Path,
|
|
2737
2833
|
gate_number: int,
|
|
@@ -2835,9 +2931,32 @@ def maybe_auto_close_terminal(
|
|
|
2835
2931
|
feature_id=feature_id,
|
|
2836
2932
|
predicate_version="v1",
|
|
2837
2933
|
)
|
|
2934
|
+
if _close_wu_disables_auto_close(close_wu_for_terminal):
|
|
2935
|
+
# Substantive close marked must-run (#189): dispatch it, don't skip.
|
|
2936
|
+
return False, AutoCloseDecision(
|
|
2937
|
+
auto=False,
|
|
2938
|
+
reasons=["auto_close_disabled_per_wu"],
|
|
2939
|
+
metrics={},
|
|
2940
|
+
gate_id=gate.number,
|
|
2941
|
+
feature_id=feature_id,
|
|
2942
|
+
predicate_version="v1",
|
|
2943
|
+
)
|
|
2838
2944
|
decision = evaluate_auto_close(feature_dir, gate.number)
|
|
2839
2945
|
if not decision.auto:
|
|
2840
2946
|
return False, decision
|
|
2947
|
+
present, reason = _gate_impl_deliverables_present(feature_dir, gate)
|
|
2948
|
+
if not present:
|
|
2949
|
+
# A gate WU reported done without its declared deliverable on disk;
|
|
2950
|
+
# do not auto-close over the discrepancy — dispatch the close, which is
|
|
2951
|
+
# where it surfaces (#190).
|
|
2952
|
+
return False, AutoCloseDecision(
|
|
2953
|
+
auto=False,
|
|
2954
|
+
reasons=[f"declared_deliverable_missing: {reason}"],
|
|
2955
|
+
metrics=decision.metrics,
|
|
2956
|
+
gate_id=gate.number,
|
|
2957
|
+
feature_id=feature_id,
|
|
2958
|
+
predicate_version=decision.predicate_version,
|
|
2959
|
+
)
|
|
2841
2960
|
write_stub_retrospective_terminal(feature_dir, gate.number, decision)
|
|
2842
2961
|
mark_close_wu_auto_closed(close_wu_for_terminal, decision)
|
|
2843
2962
|
metrics = decision.metrics
|
|
@@ -2948,9 +3067,31 @@ def maybe_auto_close_intermediate(
|
|
|
2948
3067
|
feature_id=feature_id,
|
|
2949
3068
|
predicate_version="v1",
|
|
2950
3069
|
)
|
|
3070
|
+
if _close_wu_disables_auto_close(close_intermediate_wu):
|
|
3071
|
+
# Substantive close-intermediate marked must-run (#189): dispatch it.
|
|
3072
|
+
return False, AutoCloseDecision(
|
|
3073
|
+
auto=False,
|
|
3074
|
+
reasons=["auto_close_disabled_per_wu"],
|
|
3075
|
+
metrics={},
|
|
3076
|
+
gate_id=gate.number,
|
|
3077
|
+
feature_id=feature_id,
|
|
3078
|
+
predicate_version="v1",
|
|
3079
|
+
)
|
|
2951
3080
|
decision = evaluate_auto_close(feature_dir, gate.number)
|
|
2952
3081
|
if not decision.auto:
|
|
2953
3082
|
return False, decision
|
|
3083
|
+
present, reason = _gate_impl_deliverables_present(feature_dir, gate)
|
|
3084
|
+
if not present:
|
|
3085
|
+
# See maybe_auto_close_terminal: don't auto-close over a done WU whose
|
|
3086
|
+
# declared deliverable is absent — dispatch the close instead (#190).
|
|
3087
|
+
return False, AutoCloseDecision(
|
|
3088
|
+
auto=False,
|
|
3089
|
+
reasons=[f"declared_deliverable_missing: {reason}"],
|
|
3090
|
+
metrics=decision.metrics,
|
|
3091
|
+
gate_id=gate.number,
|
|
3092
|
+
feature_id=feature_id,
|
|
3093
|
+
predicate_version=decision.predicate_version,
|
|
3094
|
+
)
|
|
2954
3095
|
append_stub_retrospective_intermediate(feature_dir, gate.number, decision)
|
|
2955
3096
|
if close_intermediate_wu is not None:
|
|
2956
3097
|
write_frontmatter_field(close_intermediate_wu.file, "status", "done")
|
|
@@ -4274,17 +4415,33 @@ def run(
|
|
|
4274
4415
|
# against head_before. Treat as a failed attempt: skip
|
|
4275
4416
|
# squash, reset the tree, record evidence, retry within
|
|
4276
4417
|
# budget. payload is the list of unchanged paths.
|
|
4418
|
+
_mm_paths = list(payload)
|
|
4277
4419
|
note = (
|
|
4278
|
-
"RESULT block declared `files_changed`
|
|
4279
|
-
"
|
|
4280
|
-
|
|
4281
|
-
+
|
|
4282
|
-
|
|
4420
|
+
"The RESULT block declared these `files_changed` "
|
|
4421
|
+
"paths, but each shows NO diff against HEAD before "
|
|
4422
|
+
"this attempt:\n"
|
|
4423
|
+
+ _annotate_unchanged_paths(_mm_paths)
|
|
4424
|
+
+ "\n\nDeclaring an unchanged path is what fails this "
|
|
4425
|
+
"guard. For each path: if it did NOT need changing, "
|
|
4426
|
+
"OMIT it from `files_changed` (declare only files you "
|
|
4427
|
+
"actually edit); if it SHOULD have changed, make the "
|
|
4428
|
+
"edit this attempt. A `(missing)` path was never "
|
|
4429
|
+
"created; a `(no diff)` path exists but is unchanged."
|
|
4283
4430
|
)
|
|
4431
|
+
# Populate failure_class/signature/excerpt (#182): a
|
|
4432
|
+
# driver-side rejection previously emitted all-null, so
|
|
4433
|
+
# events.jsonl — and gate-status, which reads only it —
|
|
4434
|
+
# showed empty columns exactly where the diagnosis lives.
|
|
4435
|
+
_mm_sig = ", ".join(
|
|
4436
|
+
sorted({Path(str(p)).name for p in _mm_paths})
|
|
4437
|
+
)[:100] or "unchanged"
|
|
4284
4438
|
wu_events.append(emit_attempt_outcome(
|
|
4285
4439
|
wu, attempt, "files_changed_mismatch",
|
|
4286
4440
|
attempts_usage[-1],
|
|
4287
|
-
|
|
4441
|
+
failure_class="files_changed_mismatch",
|
|
4442
|
+
failure_signature=_mm_sig,
|
|
4443
|
+
failure_excerpt=extract_failure_excerpt(note),
|
|
4444
|
+
extras={"unchanged_paths": _mm_paths},
|
|
4288
4445
|
))
|
|
4289
4446
|
attempt_notes.append((attempt, note))
|
|
4290
4447
|
failure_note = note
|
|
@@ -245,6 +245,17 @@ class TestAutoCloseIntermediateFiresOnCleanGate(unittest.TestCase):
|
|
|
245
245
|
self.events_path, self.root, self.ci_wu, self.plan_next_wu,
|
|
246
246
|
)
|
|
247
247
|
|
|
248
|
+
def test_per_wu_opt_out_refuses(self):
|
|
249
|
+
# #189: close-intermediate marked auto_close_disabled → not auto-closed.
|
|
250
|
+
self.ci_wu.file.write_text(
|
|
251
|
+
f"---\nid: {self.feature_id}/G1-CI\ntype: close-intermediate\n"
|
|
252
|
+
f"model: opus\nstatus: pending\nattempts: 0\n"
|
|
253
|
+
f"auto_close_disabled: true\n---\n\n# CI\n"
|
|
254
|
+
)
|
|
255
|
+
result, decision = self._call()
|
|
256
|
+
self.assertFalse(result)
|
|
257
|
+
self.assertIn("auto_close_disabled_per_wu", decision.reasons)
|
|
258
|
+
|
|
248
259
|
def test_returns_true_and_decision(self):
|
|
249
260
|
result, decision = self._call()
|
|
250
261
|
self.assertTrue(result)
|
|
@@ -256,6 +256,117 @@ class TestMaybeAutoCloseTerminalAutoPath(unittest.TestCase):
|
|
|
256
256
|
# ---------------------------------------------------------------------------
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
class TestMaybeAutoCloseTerminalPerWuOptOut(unittest.TestCase):
|
|
260
|
+
"""#189: a close WU marked auto_close_disabled must NOT be auto-closed,
|
|
261
|
+
even on an otherwise clean/on-plan gate — it is dispatched instead."""
|
|
262
|
+
|
|
263
|
+
def setUp(self):
|
|
264
|
+
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
|
265
|
+
self.root = Path(self._tmp.name)
|
|
266
|
+
self.feature_id = "FEAT-2026-9981"
|
|
267
|
+
self.gate_num = 1
|
|
268
|
+
fdir = self.root
|
|
269
|
+
self.fdir = fdir
|
|
270
|
+
_write_plan_md(fdir, self.feature_id, self.gate_num)
|
|
271
|
+
_write_wu_impl(fdir, f"{self.feature_id}/T01")
|
|
272
|
+
# Close WU carries the per-WU opt-out.
|
|
273
|
+
(fdir / "WU-close.md").write_text(
|
|
274
|
+
f"---\nid: {self.feature_id}/G{self.gate_num}-CLOSE\ntype: close\n"
|
|
275
|
+
f"model: opus\nstatus: pending\nattempts: 0\n"
|
|
276
|
+
f"auto_close_disabled: true\n---\n\n# Close\n"
|
|
277
|
+
)
|
|
278
|
+
_write_gate_file(fdir, self.gate_num)
|
|
279
|
+
_write_task_completed_event(fdir, f"{self.feature_id}/T01")
|
|
280
|
+
self.gate = _make_gate_node(fdir, self.feature_id, self.gate_num)
|
|
281
|
+
self.close_wu = _make_close_wu(
|
|
282
|
+
fdir, f"{self.feature_id}/G{self.gate_num}-CLOSE")
|
|
283
|
+
self.events_path = fdir / "events.jsonl"
|
|
284
|
+
|
|
285
|
+
def tearDown(self):
|
|
286
|
+
self._tmp.cleanup()
|
|
287
|
+
|
|
288
|
+
def test_refuses_and_names_reason(self):
|
|
289
|
+
result, decision = loop.maybe_auto_close_terminal(
|
|
290
|
+
self.fdir, self.feature_id, self.gate, [self.gate],
|
|
291
|
+
self.events_path, self.close_wu, repo_root=self.root,
|
|
292
|
+
)
|
|
293
|
+
self.assertFalse(result)
|
|
294
|
+
self.assertFalse(decision.auto)
|
|
295
|
+
self.assertIn("auto_close_disabled_per_wu", decision.reasons)
|
|
296
|
+
|
|
297
|
+
def test_close_wu_not_marked_auto_closed(self):
|
|
298
|
+
loop.maybe_auto_close_terminal(
|
|
299
|
+
self.fdir, self.feature_id, self.gate, [self.gate],
|
|
300
|
+
self.events_path, self.close_wu, repo_root=self.root,
|
|
301
|
+
)
|
|
302
|
+
fm, _ = loop.read_frontmatter(self.close_wu.file)
|
|
303
|
+
# Status untouched (still dispatchable); no auto_close output marker.
|
|
304
|
+
self.assertEqual(fm.get("status"), "pending")
|
|
305
|
+
self.assertNotEqual(fm.get("auto_close"), True)
|
|
306
|
+
|
|
307
|
+
def test_helper_reads_the_flag(self):
|
|
308
|
+
self.assertTrue(loop._close_wu_disables_auto_close(self.close_wu))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class TestMaybeAutoCloseTerminalDeliverableMissing(unittest.TestCase):
|
|
312
|
+
"""#190: auto-close must refuse when an implementation WU reports done but
|
|
313
|
+
its declared `produces:` deliverable is absent — dispatch the close."""
|
|
314
|
+
|
|
315
|
+
def setUp(self):
|
|
316
|
+
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
|
317
|
+
self.root = Path(self._tmp.name)
|
|
318
|
+
self.feature_id = "FEAT-2026-9982"
|
|
319
|
+
self.gate_num = 1
|
|
320
|
+
fdir = self.root
|
|
321
|
+
self.fdir = fdir
|
|
322
|
+
_write_plan_md(fdir, self.feature_id, self.gate_num)
|
|
323
|
+
# Implementation WU claims done + a produces path that does NOT exist.
|
|
324
|
+
missing = fdir / "src" / "never-created.py"
|
|
325
|
+
(fdir / "WU-01.md").write_text(
|
|
326
|
+
f"---\nid: {self.feature_id}/T01\ntype: implementation\n"
|
|
327
|
+
f"model: sonnet\nstatus: done\nattempts: 1\ncost_usd: 0.5\n"
|
|
328
|
+
f"planned_cost_usd: 0.5\nproduces: [{missing}]\n---\n\n# T01\n"
|
|
329
|
+
)
|
|
330
|
+
_write_wu_close(fdir, f"{self.feature_id}/G{self.gate_num}-CLOSE")
|
|
331
|
+
_write_gate_file(fdir, self.gate_num)
|
|
332
|
+
_write_task_completed_event(fdir, f"{self.feature_id}/T01")
|
|
333
|
+
self.gate = _make_gate_node(fdir, self.feature_id, self.gate_num)
|
|
334
|
+
self.close_wu = _make_close_wu(
|
|
335
|
+
fdir, f"{self.feature_id}/G{self.gate_num}-CLOSE")
|
|
336
|
+
self.events_path = fdir / "events.jsonl"
|
|
337
|
+
|
|
338
|
+
def tearDown(self):
|
|
339
|
+
self._tmp.cleanup()
|
|
340
|
+
|
|
341
|
+
def _call(self):
|
|
342
|
+
return loop.maybe_auto_close_terminal(
|
|
343
|
+
self.fdir, self.feature_id, self.gate, [self.gate],
|
|
344
|
+
self.events_path, self.close_wu, repo_root=self.root,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def test_refuses_on_missing_deliverable(self):
|
|
348
|
+
result, decision = self._call()
|
|
349
|
+
self.assertFalse(result)
|
|
350
|
+
self.assertFalse(decision.auto)
|
|
351
|
+
self.assertTrue(any("declared_deliverable_missing" in r
|
|
352
|
+
for r in decision.reasons),
|
|
353
|
+
f"reasons={decision.reasons}")
|
|
354
|
+
|
|
355
|
+
def test_present_deliverable_still_auto_closes(self):
|
|
356
|
+
# Create the declared deliverable → predicate proceeds to auto-close.
|
|
357
|
+
missing = self.fdir / "src" / "never-created.py"
|
|
358
|
+
missing.parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
missing.write_text("x = 1\n")
|
|
360
|
+
result, decision = self._call()
|
|
361
|
+
self.assertTrue(result)
|
|
362
|
+
self.assertTrue(decision.auto)
|
|
363
|
+
|
|
364
|
+
def test_helper_reports_the_offender(self):
|
|
365
|
+
ok, reason = loop._gate_impl_deliverables_present(self.fdir, self.gate)
|
|
366
|
+
self.assertFalse(ok)
|
|
367
|
+
self.assertIn("never-created.py", reason)
|
|
368
|
+
|
|
369
|
+
|
|
259
370
|
class TestMaybeAutoCloseTerminalNonAutoPath(unittest.TestCase):
|
|
260
371
|
|
|
261
372
|
def setUp(self):
|
|
@@ -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:
|
|
@@ -133,6 +133,15 @@ class TestVerifyFilesChanged(unittest.TestCase):
|
|
|
133
133
|
self.assertEqual(
|
|
134
134
|
loop.verify_files_changed(result, self.head), ["ignored.log"])
|
|
135
135
|
|
|
136
|
+
def test_annotate_distinguishes_missing_from_no_diff(self):
|
|
137
|
+
# #182: never label an existing-but-unchanged file as deleted. An
|
|
138
|
+
# on-disk path → "(no diff)"; an absent path → "(missing)".
|
|
139
|
+
(self.root / "present.py").write_text("x\n")
|
|
140
|
+
out = loop._annotate_unchanged_paths(["present.py", "gone.py"])
|
|
141
|
+
self.assertIn("(no diff) present.py", out)
|
|
142
|
+
self.assertIn("(missing) gone.py", out)
|
|
143
|
+
self.assertNotIn("deleted", out)
|
|
144
|
+
|
|
136
145
|
|
|
137
146
|
def write_minimal_feature(root: Path, feature_id: str, slug: str,
|
|
138
147
|
branch: str, wus: list) -> Path:
|
|
@@ -302,6 +311,13 @@ class TestFilesChangedMismatchSpinsIntegration(unittest.TestCase):
|
|
|
302
311
|
self.assertEqual(ev["payload"]["unchanged_paths"],
|
|
303
312
|
["sentinel.py"],
|
|
304
313
|
"payload must name the sentinel path")
|
|
314
|
+
# #182: driver-side rejections must populate the diagnosis
|
|
315
|
+
# fields, not emit all-null — gate-status reads only events.jsonl.
|
|
316
|
+
self.assertEqual(ev["payload"]["failure_class"],
|
|
317
|
+
"files_changed_mismatch")
|
|
318
|
+
self.assertIn("sentinel.py", ev["payload"]["failure_signature"])
|
|
319
|
+
self.assertTrue(ev["payload"]["failure_excerpt"],
|
|
320
|
+
"failure_excerpt must not be null/empty")
|
|
305
321
|
|
|
306
322
|
types = [e["event_type"] for e in events]
|
|
307
323
|
self.assertNotIn("task_completed", types,
|
|
@@ -343,6 +343,31 @@ class TestFindFeatureSelection(unittest.TestCase):
|
|
|
343
343
|
# The bad one is skipped; the good active feature still resolves.
|
|
344
344
|
self.assertEqual(loop.find_feature(None), good)
|
|
345
345
|
|
|
346
|
+
def test_explicit_deferred_feature_is_refused(self):
|
|
347
|
+
"""#183: `--feature` on a deferred feature must refuse, not dispatch."""
|
|
348
|
+
self._write_plan("FEAT-2026-9502-parked", "deferred")
|
|
349
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
350
|
+
loop.find_feature("FEAT-2026-9502-parked")
|
|
351
|
+
msg = str(ctx.exception.code).lower()
|
|
352
|
+
self.assertIn("deferred", msg)
|
|
353
|
+
self.assertIn("active", msg) # points at the fix
|
|
354
|
+
|
|
355
|
+
def test_deferred_not_treated_as_active_in_scan(self):
|
|
356
|
+
"""#183: a lone deferred feature yields 'no active', named as parked."""
|
|
357
|
+
self._write_plan("FEAT-2026-9502-parked", "deferred")
|
|
358
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
359
|
+
loop.find_feature(None)
|
|
360
|
+
msg = str(ctx.exception.code)
|
|
361
|
+
self.assertIn("No active feature", msg)
|
|
362
|
+
self.assertIn("parked", msg.lower())
|
|
363
|
+
self.assertIn("FEAT-2026-9502-parked", msg)
|
|
364
|
+
|
|
365
|
+
def test_deferred_does_not_block_a_real_active(self):
|
|
366
|
+
"""A deferred feature alongside one active resolves to the active."""
|
|
367
|
+
self._write_plan("FEAT-2026-9502-parked", "deferred")
|
|
368
|
+
good = self._write_plan("FEAT-2026-9502-live", "active")
|
|
369
|
+
self.assertEqual(loop.find_feature(None), good)
|
|
370
|
+
|
|
346
371
|
def test_read_frontmatter_error_names_the_file(self):
|
|
347
372
|
"""#177 fix 1: a parse error carries the file path, not just a
|
|
348
373
|
block-relative line number."""
|
|
@@ -370,11 +395,13 @@ class TestFindFeatureSelection(unittest.TestCase):
|
|
|
370
395
|
self._write_plan("FEAT-2026-9502-parked", "deferred")
|
|
371
396
|
self.assertEqual(loop.find_feature(None), active_dir)
|
|
372
397
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
398
|
+
# NOTE (#183): the prior test asserted a `deferred` feature was still
|
|
399
|
+
# dispatchable via an explicit --feature (only auto-selection skipped it,
|
|
400
|
+
# from #106). That contradicted the shipped contract — the PLAN template,
|
|
401
|
+
# methodology.md, and the gate-status skill all document `deferred` as
|
|
402
|
+
# "non-dispatchable". #183 resolves the contradiction in favor of the docs:
|
|
403
|
+
# explicit dispatch is now refused (see test_explicit_deferred_feature_is_
|
|
404
|
+
# refused above), so that assertion is intentionally removed.
|
|
378
405
|
|
|
379
406
|
def test_explicit_arg_without_plan_exits(self):
|
|
380
407
|
"""Lines 188-190: explicit dir name with no PLAN.md → sys.exit."""
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.18
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{specfuse_loop-0.3.18 → specfuse_loop-0.3.20}/specfuse/loop/data/docs/concepts/ralph-lineage.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|