specfuse-loop 0.3.22__tar.gz → 0.3.23__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.22/specfuse_loop.egg-info → specfuse_loop-0.3.23}/PKG-INFO +1 -1
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/pyproject.toml +1 -1
- specfuse_loop-0.3.23/specfuse/loop/data/VERSION +1 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/methodology.md +12 -0
- specfuse_loop-0.3.23/specfuse/loop/data/rules/close-discipline.md +74 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/templates/WU.template.md +20 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/lint_plan.py +52 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/loop.py +12 -9
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/scaffold.py +75 -5
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23/specfuse_loop.egg-info}/PKG-INFO +1 -1
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/SOURCES.txt +2 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_init_integration.py +2 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_close_intermediate.py +8 -0
- specfuse_loop-0.3.23/tests/test_lint_planning_sections.py +117 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_gate_budget.py +28 -4
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_data_in_sync.py +1 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_docs.py +7 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_init.py +2 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_resources.py +1 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_upgrade.py +55 -1
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_upgrade_integration.py +8 -0
- specfuse_loop-0.3.22/specfuse/loop/data/VERSION +0 -1
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/LICENSE +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/NOTICE +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/README.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/setup.cfg +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/__init__.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/_filelock.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/_miniyaml.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/adopt_feature.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/LEARNINGS.template.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/concepts/ralph-lineage.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/getting-started.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/docs/skills.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/gitignore.snippet +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/roadmap.template.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/correlation-ids.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/never-touch.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/planning-discipline.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/result-contract.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/security-boundaries.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules/verification-discipline.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/rules-local/README.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/event.schema.json +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/initiative_created.schema.json +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/spec_issue_resolved.schema.json +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/spec_issue_routed.schema.json +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/schemas/events/spec_validated.schema.json +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/templates/GATE.template.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/templates/PLAN.template.md +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/data/verification.yml.example +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/gate_eval.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/gh_backend.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/gh_features.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse/loop/validate_event.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/dependency_links.txt +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/entry_points.txt +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/requires.txt +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/specfuse_loop.egg-info/top_level.txt +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_adopt_feature.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_arm_gate_edits_uncommitted.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_attempt_outcome_emission.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autoclose_deferral_visibility.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync_consent.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync_firstrun.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_autosync_plugin.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_backend.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bash_routing.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bookkeeping_commit_crash_run.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bookkeeping_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_bookkeeping_no_verify.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_claude_resolution.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_close_wu_self_reference.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_closing_deliverable_guard.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_console_encoding.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_cost_tracking.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_degraded_oracle.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_deliverable_presence_gate.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_doctor.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_driver_integration.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_driver_lock.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_duration_tracking.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_empty_files_escalation.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_ensure_feature_branch.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_ensure_feature_branch_base.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_events_redaction.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_extra_gates.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_filelock_portable.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_force_full_close.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval_calibration.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval_intermediate_wiring.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gate_eval_terminal_wiring.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gh_backend.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_gh_features.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_git_env_isolation.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_hashed_denylist.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_hashed_denylist_ci.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_interpreter_normalization.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_leak_findings_redaction.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_leak_scan.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_leak_scan_content.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_learnings_query.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_legacy_4wu_terminal_flips.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lifecycle_integration.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lifetime_accounting.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_bare_produces_path.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_close_wu.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_correlation_id.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_correlation_id_close_intermediate.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_oracle_env.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_plan_errors.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_plan_next_draft.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_plan_relative_import.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_produces_driver_helper.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_sections.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_task_graph_yaml_selection.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_lint_verification_reference.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_attempt_evidence.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_auto_archive.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_caveman_preamble.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_close_intermediate.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_defaults_by_type.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_effort.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_failure_note_cap.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_files_changed_guard.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_model_alias.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_orchestration.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_post_pass_invariant.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_reset_preserving_events.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_smoke_runner.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_unsandboxed.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_loop_zero_token_guard.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_migrate_legacy.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_miniyaml_equivalence.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_miniyaml_negative.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_planned_cost_lint.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_prepare_feature.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_prepare_scaffold_sync.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_produces_field.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_produces_in_diff.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_redaction_windows_home.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_resolve_base.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_result_block.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_roadmap_add_skill.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_roadmap_archive_skill.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_roadmap_row_parser.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_doc_hygiene.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_manifest.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_seed_sanity.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_scaffold_wiring.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_skills_vendored_in_sync.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_spinning_rearm_gate.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_squash_commit_hook_crash.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_template_closing_shapes.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_terminal_flip_ownership.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_terminal_flips.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_timeout_kill_portable.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_untracked_feature_folder.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_untracked_file_absorption.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_upgrade_merge_gate.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_validate_event.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_verdict_coupling.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_verify_empty_gate_set.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_version_consistency.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_version_skew.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/tests/test_windows_gate_exec.py +0 -0
- {specfuse_loop-0.3.22 → specfuse_loop-0.3.23}/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.23"
|
|
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.23
|
|
@@ -147,6 +147,18 @@ WU's** frontmatter. The predicate keys on the implementation WUs' cost and
|
|
|
147
147
|
plan-conformance and cannot see what a close verifies, so a substantive close
|
|
148
148
|
must be marked must-run or its verification is silently skipped (#189).
|
|
149
149
|
|
|
150
|
+
**Hedged verdicts leave a trail** — a close that ends `met_locally` (everything
|
|
151
|
+
locally verifiable passed; something structurally could not be verified here)
|
|
152
|
+
leaves every terminal surface un-flipped (driver >= 0.3.21: gate
|
|
153
|
+
`awaiting_review`, roadmap row `active`, PLAN.md `active`) AND must produce a
|
|
154
|
+
follow-up record — in the gate review or `RETROSPECTIVE.md` — naming each
|
|
155
|
+
unmet criterion, why it was unverifiable in this environment, and the exact
|
|
156
|
+
re-run condition that would upgrade the verdict to `met`. Without the record,
|
|
157
|
+
`met_locally` is a dead end; with it, the feature is resumable by anyone. See
|
|
158
|
+
`.specfuse/rules/close-discipline.md` §2 for the record's shape and the other
|
|
159
|
+
close-time obligations (fresh oracle re-runs, consumer-visible-change
|
|
160
|
+
enumeration).
|
|
161
|
+
|
|
150
162
|
**Predicate-version transparency** — every `auto_close_decision` event in
|
|
151
163
|
`events.jsonl` carries a `predicate_version` field (e.g., `predicate_version:
|
|
152
164
|
v1`). Future revisions to the predicate constants increment this version, so
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copyright 2026 Specfuse Contributors
|
|
3
|
+
Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
-->
|
|
5
|
+
|
|
6
|
+
# Rule: close discipline
|
|
7
|
+
|
|
8
|
+
Three obligations for `close` and `close-intermediate` WUs, binding at close
|
|
9
|
+
time. Sibling of [`planning-discipline.md`](planning-discipline.md) (plan/arm
|
|
10
|
+
time) and of the per-WU closing obligations in
|
|
11
|
+
[`result-contract.md`](result-contract.md). Provenance: FEAT-2026-0049
|
|
12
|
+
(the specfuse-generator dogfood), where three gates auto-closed at
|
|
13
|
+
`attempts: 0` and the one close forced to run caught two false-`done` WUs and
|
|
14
|
+
an unsatisfiable predicate — 48% of the feature's spend landed after the
|
|
15
|
+
terminal gate had already "auto-closed". A close that only writes prose
|
|
16
|
+
verifies nothing.
|
|
17
|
+
|
|
18
|
+
A close whose acceptance criteria include ANY obligation below is
|
|
19
|
+
**load-bearing**: the plan author sets `auto_close_disabled: true` in that
|
|
20
|
+
WU's frontmatter so the auto-close predicate cannot optimize it away (#189).
|
|
21
|
+
|
|
22
|
+
## 1. Oracles re-run fresh
|
|
23
|
+
|
|
24
|
+
The close re-runs every oracle the feature's acceptance criteria name — the
|
|
25
|
+
full test command(s), plus any compile/execution gates — fresh, in this
|
|
26
|
+
session, exit codes read directly. Never inherit a producing WU's
|
|
27
|
+
self-report: `done` is a claim, the re-run is the verification. When the
|
|
28
|
+
feature's criteria assert on generated artifacts, regenerate into a clean
|
|
29
|
+
output directory before asserting — stale output satisfies any assertion.
|
|
30
|
+
|
|
31
|
+
> **Provenance.** A WU reported `done` while its source was untouched and its
|
|
32
|
+
> oracle never ran; the driver-side produces-vs-diff guard now refuses that
|
|
33
|
+
> pass (specfuse-loop >= 0.3.21), but only the close's own fresh re-run
|
|
34
|
+
> catches the composite: all WUs individually green while the feature-level
|
|
35
|
+
> oracle fails.
|
|
36
|
+
|
|
37
|
+
## 2. Hedged-verdict follow-up record
|
|
38
|
+
|
|
39
|
+
On `met_locally`, the close must produce a named record — in the gate review
|
|
40
|
+
or `RETROSPECTIVE.md` — with one entry per unmet criterion:
|
|
41
|
+
|
|
42
|
+
- the criterion, verbatim;
|
|
43
|
+
- why it is unverifiable in this environment;
|
|
44
|
+
- the exact re-run condition that would upgrade the verdict to `met`.
|
|
45
|
+
|
|
46
|
+
The driver (>= 0.3.21) already guarantees the surfaces stay un-flipped on a
|
|
47
|
+
hedged verdict (gate `awaiting_review`, roadmap `active`, PLAN `active`);
|
|
48
|
+
this record is the other half: without it, `met_locally` is a dead end —
|
|
49
|
+
no artifact says what would make it `met`, and the honest hedged verdict
|
|
50
|
+
degrades into a polite synonym for "unknown".
|
|
51
|
+
|
|
52
|
+
## 3. Consumer-visible contract changes enumerated, human-acknowledged
|
|
53
|
+
|
|
54
|
+
The close enumerates every consumer-visible addition, removal, or rename the
|
|
55
|
+
feature makes across ALL its producing WUs — API surface, generated models,
|
|
56
|
+
published schemas, CLI flags, whatever contract consumers depend on — and
|
|
57
|
+
blocks on explicit human acknowledgment of the list. A feature with no such
|
|
58
|
+
changes writes exactly: `n/a — no consumer-visible contract change` (do not
|
|
59
|
+
fabricate an empty enumeration; the n/a line is the reviewed claim).
|
|
60
|
+
|
|
61
|
+
A close carrying this obligation is always load-bearing
|
|
62
|
+
(`auto_close_disabled: true`).
|
|
63
|
+
|
|
64
|
+
> **Provenance.** A breaking removal from a generated model survived four
|
|
65
|
+
> gates unreviewed because no close surface owned the breaking-change list.
|
|
66
|
+
> A silent breaking change is the most expensive false-done, and it is not
|
|
67
|
+
> language-specific — any generated or published contract has this surface.
|
|
68
|
+
|
|
69
|
+
## Split with project-local rules
|
|
70
|
+
|
|
71
|
+
These are the generic obligations. The concrete grounding — which command is
|
|
72
|
+
the oracle, which surface is "the API", project-specific regeneration
|
|
73
|
+
hazards — is per-project and belongs in that project's
|
|
74
|
+
`.specfuse/rules-local/` (never touched by upgrade), referencing this rule.
|
|
@@ -92,6 +92,26 @@ defect gates later.
|
|
|
92
92
|
|---|---|---|
|
|
93
93
|
| `<path/method>` | yes / no | <one line> |
|
|
94
94
|
|
|
95
|
+
**Close obligations** (REQUIRED only for `close` / `close-intermediate` WUs; omit
|
|
96
|
+
otherwise — see `.specfuse/rules/close-discipline.md`). The close's acceptance
|
|
97
|
+
criteria must cover:
|
|
98
|
+
|
|
99
|
+
1. **Oracles re-run fresh** (§1): every oracle the feature's criteria name,
|
|
100
|
+
full command(s), exit codes read directly — never a producing WU's
|
|
101
|
+
self-report; regenerate into a clean dir before asserting on generated
|
|
102
|
+
artifacts.
|
|
103
|
+
2. **Hedged follow-up record** (§2): on `met_locally`, a named record per
|
|
104
|
+
unmet criterion — criterion, why unverifiable here, exact re-run condition
|
|
105
|
+
that upgrades it to `met`.
|
|
106
|
+
3. **Consumer-visible contract changes** (§3): enumerate every addition /
|
|
107
|
+
removal / rename across the feature's producing WUs and block on human
|
|
108
|
+
acknowledgment, or write exactly `n/a — no consumer-visible contract
|
|
109
|
+
change`.
|
|
110
|
+
|
|
111
|
+
A close whose criteria include any of these is load-bearing — set
|
|
112
|
+
`auto_close_disabled: true` in this WU's frontmatter so the auto-close
|
|
113
|
+
predicate cannot skip it.
|
|
114
|
+
|
|
95
115
|
**Do not touch.** Generated directories (`_generated/`, `gen-src/`, or the repo's
|
|
96
116
|
declared equivalent), files owned by other work units in this gate, secrets,
|
|
97
117
|
`.git/`. The driver owns all git operations — you edit files only. See
|
|
@@ -335,6 +335,56 @@ def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
|
|
|
335
335
|
)
|
|
336
336
|
|
|
337
337
|
|
|
338
|
+
# Planning-discipline section names (#201, second half). Canonical since the
|
|
339
|
+
# FEAT-2026-0049 planning-discipline drop (PR #211): PLAN.md carries the two
|
|
340
|
+
# ADR sections; each gate file carries the arming-discipline section. Names
|
|
341
|
+
# are shared vocabulary with downstream planning tooling — do not rename here
|
|
342
|
+
# without a template/coordination change.
|
|
343
|
+
PLAN_DISCIPLINE_SECTIONS = (
|
|
344
|
+
"Existing-mechanism search",
|
|
345
|
+
"Escalation-predicate satisfiability",
|
|
346
|
+
)
|
|
347
|
+
GATE_ARMING_SECTION = "Arming discipline"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def check_planning_sections(
|
|
351
|
+
feature_dir: Path, plan_fm: dict, plan_body: str, gates: list,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""WARN when the planning-discipline sections are absent (#201).
|
|
354
|
+
|
|
355
|
+
Presence-only: the PLAN template's sections allow an explicit n/a line, so
|
|
356
|
+
content is the arming reviewer's job — this check only catches the section
|
|
357
|
+
never being written at all (pre-0.3.22 drafts, or a plan-next that dropped
|
|
358
|
+
it). Sealed features (plan status done/abandoned) are history and skipped.
|
|
359
|
+
WARN-only, exit code 0, like the rest of the planning-lint family.
|
|
360
|
+
"""
|
|
361
|
+
if plan_fm.get("status") in ("done", "abandoned"):
|
|
362
|
+
return
|
|
363
|
+
for section in PLAN_DISCIPLINE_SECTIONS:
|
|
364
|
+
if not _slice_section(plan_body, section).strip():
|
|
365
|
+
print(
|
|
366
|
+
f"WARN: {feature_dir}/PLAN.md: missing '{section}' section "
|
|
367
|
+
f"(planning-discipline; write it or an explicit 'n/a' line). "
|
|
368
|
+
f"See .specfuse/rules/planning-discipline.md."
|
|
369
|
+
)
|
|
370
|
+
for g in gates:
|
|
371
|
+
gate_file = g.get("file")
|
|
372
|
+
if not gate_file:
|
|
373
|
+
continue
|
|
374
|
+
gate_path = feature_dir / gate_file
|
|
375
|
+
if not gate_path.exists():
|
|
376
|
+
continue
|
|
377
|
+
gfm, gbody = read_frontmatter(gate_path)
|
|
378
|
+
if gfm.get("status") == "passed":
|
|
379
|
+
continue
|
|
380
|
+
if not _slice_section(gbody, GATE_ARMING_SECTION).strip():
|
|
381
|
+
print(
|
|
382
|
+
f"WARN: {gate_file}: missing '{GATE_ARMING_SECTION}' section "
|
|
383
|
+
f"(runtime-probe / flag-scope / predicate checks before "
|
|
384
|
+
f"arming). See .specfuse/rules/planning-discipline.md."
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
338
388
|
def lint(feature_dir: Path) -> list[str]:
|
|
339
389
|
errs: list[str] = []
|
|
340
390
|
plan = feature_dir / "PLAN.md"
|
|
@@ -623,6 +673,8 @@ def lint(feature_dir: Path) -> list[str]:
|
|
|
623
673
|
|
|
624
674
|
# Planned-cost capture: WARN on missing/divergent planned_cost_usd fields.
|
|
625
675
|
check_planned_cost(feature_dir, fm, gates)
|
|
676
|
+
# Planning-discipline section presence (#201): WARN-only.
|
|
677
|
+
check_planning_sections(feature_dir, fm, body, gates)
|
|
626
678
|
|
|
627
679
|
# Cross-gate mixed-shape check. Two directions of mix:
|
|
628
680
|
#
|
|
@@ -64,7 +64,7 @@ SPECFUSE_DIR = Path(".specfuse")
|
|
|
64
64
|
REPO_ROOT = SPECFUSE_DIR.parent
|
|
65
65
|
FEATURES_DIR = SPECFUSE_DIR / "features"
|
|
66
66
|
VERIFICATION_PATH = SPECFUSE_DIR / "verification.yml"
|
|
67
|
-
DRIVER_VERSION = "0.3.
|
|
67
|
+
DRIVER_VERSION = "0.3.23"
|
|
68
68
|
# Oldest scaffold layout this driver can drive. init.sh stamps the scaffold's own
|
|
69
69
|
# version into `.specfuse/VERSION`; check_scaffold_version() fails loud at startup if
|
|
70
70
|
# the consumer's scaffold is older than this, pointing at `specfuse upgrade`. Bump
|
|
@@ -1537,15 +1537,20 @@ def gate_budget_usd(gate_file: Path) -> float | None:
|
|
|
1537
1537
|
|
|
1538
1538
|
|
|
1539
1539
|
def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
|
|
1540
|
-
"""Sum lifetime cost across the gate's
|
|
1540
|
+
"""Sum lifetime recorded cost across ALL of the gate's WUs.
|
|
1541
1541
|
|
|
1542
1542
|
Reads each WU file's frontmatter from `gate["work_units"]` and adds
|
|
1543
1543
|
`cost_usd` PLUS `cumulative_cost_usd` (prior re-arm cycles' spend, folded
|
|
1544
|
-
by fold_cumulative_on_rearm — #199)
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1544
|
+
by fold_cumulative_on_rearm — #199) regardless of WU status (#219):
|
|
1545
|
+
write_cost_to_wu records cost on every outcome — pass, blocked, spinning —
|
|
1546
|
+
so cost on a non-done WU is real spend, not an estimate. The pre-#219
|
|
1547
|
+
done-only filter left a blocked WU's re-arm burn invisible to the budget
|
|
1548
|
+
brake until the WU finally passed (FEAT-2026-0049/WU-06: $30.29 across 9
|
|
1549
|
+
attempts, contributing $0 while blocked). WUs whose frontmatter omits both
|
|
1550
|
+
fields — cost tracking off, never dispatched, or the attempt didn't record
|
|
1551
|
+
a cost — contribute 0.0. `plan` is the feature frontmatter dict and is
|
|
1552
|
+
accepted for signature symmetry with the broader gate-budget helpers; the
|
|
1553
|
+
spent total is derived from WU files alone.
|
|
1549
1554
|
"""
|
|
1550
1555
|
del plan # signature symmetry — sum is derived from WU files only
|
|
1551
1556
|
total = 0.0
|
|
@@ -1557,8 +1562,6 @@ def gate_spent_usd(plan: dict, gate: dict, feature_dir: Path) -> float:
|
|
|
1557
1562
|
if not wu_path.exists():
|
|
1558
1563
|
continue
|
|
1559
1564
|
fm, _ = read_frontmatter(wu_path)
|
|
1560
|
-
if fm.get("status") != "done":
|
|
1561
|
-
continue
|
|
1562
1565
|
# Lifetime spend, not per-cycle (#199): fold_cumulative_on_rearm
|
|
1563
1566
|
# moves each prior dispatch cycle's cost into cumulative_cost_usd and
|
|
1564
1567
|
# zeroes cost_usd, so summing cost_usd alone undercounts every
|
|
@@ -70,6 +70,27 @@ def _write_manifest(specfuse_dir: Path, entries: dict[str, str]) -> None:
|
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def _read_manifest(specfuse_dir: Path) -> dict[str, str]:
|
|
74
|
+
"""Return the on-disk .scaffold-manifest entries, {} when absent/unreadable.
|
|
75
|
+
|
|
76
|
+
The manifest is the ownership record: a versioned-dir file is provably
|
|
77
|
+
specfuse-written iff its relpath appears here (#214). Malformed content
|
|
78
|
+
degrades to {} — the callers then act as if ownership is unprovable,
|
|
79
|
+
which errs on the side of not deleting.
|
|
80
|
+
"""
|
|
81
|
+
manifest_path = specfuse_dir / _MANIFEST_FILE
|
|
82
|
+
if not manifest_path.exists():
|
|
83
|
+
return {}
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
86
|
+
except (ValueError, OSError):
|
|
87
|
+
return {}
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
return {}
|
|
90
|
+
return {k: v for k, v in data.items()
|
|
91
|
+
if isinstance(k, str) and isinstance(v, str)}
|
|
92
|
+
|
|
93
|
+
|
|
73
94
|
def detect_modified(target: str | Path) -> list[str]:
|
|
74
95
|
"""Return sorted relpaths of versioned .specfuse/ files whose sha256 differs from the manifest.
|
|
75
96
|
|
|
@@ -322,10 +343,12 @@ def upgrade_specfuse(
|
|
|
322
343
|
"""Overlay versioned seed onto an existing .specfuse/ tree.
|
|
323
344
|
|
|
324
345
|
Overwrites versioned files (templates/, rules/, verification.yml.example, VERSION),
|
|
325
|
-
prunes versioned files the seed no longer ships
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
is
|
|
346
|
+
prunes versioned files the seed no longer ships — but only those the prior
|
|
347
|
+
.scaffold-manifest proves specfuse wrote; unknown files are kept with a
|
|
348
|
+
warning (#214) — seeds missing user-authored files from templates, and
|
|
349
|
+
refreshes .claude wiring. Overwriting a file whose content is not what the
|
|
350
|
+
prior upgrade wrote warns to stderr. rules-local/ is project-owned: never
|
|
351
|
+
overlaid, never pruned; only its README is seeded when absent.
|
|
329
352
|
|
|
330
353
|
Returns the sorted list of .specfuse/ relpaths written.
|
|
331
354
|
Raises ScaffoldDowngradeError if the target VERSION is newer than the installed seed.
|
|
@@ -350,6 +373,11 @@ def upgrade_specfuse(
|
|
|
350
373
|
written: list[str] = []
|
|
351
374
|
versioned_relpaths: set[str] = set()
|
|
352
375
|
manifest_entries: dict[str, str] = {}
|
|
376
|
+
# Ownership record from the PRIOR init/upgrade (#214): a file in a
|
|
377
|
+
# versioned dir is provably specfuse-written iff it appears here. Read
|
|
378
|
+
# before the overlay overwrites anything and before _write_manifest
|
|
379
|
+
# replaces it below.
|
|
380
|
+
old_manifest = _read_manifest(specfuse_dir)
|
|
353
381
|
|
|
354
382
|
for relpath, content in iter_scaffold_files():
|
|
355
383
|
if not (
|
|
@@ -358,6 +386,32 @@ def upgrade_specfuse(
|
|
|
358
386
|
):
|
|
359
387
|
continue
|
|
360
388
|
dest = specfuse_dir / relpath
|
|
389
|
+
# Clobber warning (#214): overwriting is the versioned-dir contract,
|
|
390
|
+
# but do it loudly when the on-disk content is not what the prior
|
|
391
|
+
# upgrade wrote — either a project-authored file whose name now
|
|
392
|
+
# collides with a shipped one, or a locally-modified shipped file.
|
|
393
|
+
# A missing manifest (legacy pre-manifest tree) stays silent: every
|
|
394
|
+
# file would warn and the signal would drown.
|
|
395
|
+
if old_manifest and dest.exists():
|
|
396
|
+
on_disk_sha = _sha256_hex(dest.read_bytes())
|
|
397
|
+
if on_disk_sha != _sha256_hex(content):
|
|
398
|
+
owned_sha = old_manifest.get(relpath)
|
|
399
|
+
if owned_sha is None:
|
|
400
|
+
print(
|
|
401
|
+
f"specfuse upgrade: WARNING — overwriting {relpath}: "
|
|
402
|
+
"not written by a prior init/upgrade (project-"
|
|
403
|
+
"authored?). Project-owned rules belong in "
|
|
404
|
+
".specfuse/rules-local/, which upgrade never touches.",
|
|
405
|
+
file=sys.stderr,
|
|
406
|
+
)
|
|
407
|
+
elif on_disk_sha != owned_sha:
|
|
408
|
+
print(
|
|
409
|
+
f"specfuse upgrade: WARNING — overwriting locally-"
|
|
410
|
+
f"modified versioned file {relpath}. Upstream edits "
|
|
411
|
+
"belong in a scaffold PR; project-specific rules in "
|
|
412
|
+
".specfuse/rules-local/.",
|
|
413
|
+
file=sys.stderr,
|
|
414
|
+
)
|
|
361
415
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
362
416
|
dest.write_bytes(content)
|
|
363
417
|
written.append(relpath)
|
|
@@ -370,8 +424,24 @@ def upgrade_specfuse(
|
|
|
370
424
|
for existing in dir_path.rglob("*"):
|
|
371
425
|
if existing.is_file():
|
|
372
426
|
rel = existing.relative_to(specfuse_dir).as_posix()
|
|
373
|
-
if rel
|
|
427
|
+
if rel in versioned_relpaths:
|
|
428
|
+
continue
|
|
429
|
+
# Manifest-scoped prune (#214): delete only files the
|
|
430
|
+
# prior manifest proves specfuse wrote (shipped once,
|
|
431
|
+
# since removed from the seed). Anything else — a
|
|
432
|
+
# project-authored file, or any file on a legacy
|
|
433
|
+
# pre-manifest tree — is kept, loudly.
|
|
434
|
+
if rel in old_manifest:
|
|
374
435
|
existing.unlink()
|
|
436
|
+
else:
|
|
437
|
+
print(
|
|
438
|
+
f"specfuse upgrade: WARNING — keeping {rel}: not "
|
|
439
|
+
"in the shipped scaffold and not written by a "
|
|
440
|
+
"prior init/upgrade. If it is project-authored, "
|
|
441
|
+
"move it to .specfuse/rules-local/ (never touched "
|
|
442
|
+
"by upgrade); if it is stale, delete it manually.",
|
|
443
|
+
file=sys.stderr,
|
|
444
|
+
)
|
|
375
445
|
|
|
376
446
|
user_seeds = [
|
|
377
447
|
("LEARNINGS.md", "LEARNINGS.template.md"),
|
|
@@ -23,6 +23,7 @@ specfuse/loop/data/docs/methodology.md
|
|
|
23
23
|
specfuse/loop/data/docs/skills.md
|
|
24
24
|
specfuse/loop/data/docs/concepts/architecture-addendum-gates-and-iterative-planning.md
|
|
25
25
|
specfuse/loop/data/docs/concepts/ralph-lineage.md
|
|
26
|
+
specfuse/loop/data/rules/close-discipline.md
|
|
26
27
|
specfuse/loop/data/rules/correlation-ids.md
|
|
27
28
|
specfuse/loop/data/rules/never-touch.md
|
|
28
29
|
specfuse/loop/data/rules/planning-discipline.md
|
|
@@ -102,6 +103,7 @@ tests/test_lint_oracle_env.py
|
|
|
102
103
|
tests/test_lint_plan_errors.py
|
|
103
104
|
tests/test_lint_plan_next_draft.py
|
|
104
105
|
tests/test_lint_plan_relative_import.py
|
|
106
|
+
tests/test_lint_planning_sections.py
|
|
105
107
|
tests/test_lint_produces_driver_helper.py
|
|
106
108
|
tests/test_lint_sections.py
|
|
107
109
|
tests/test_lint_task_graph_yaml_selection.py
|
|
@@ -34,6 +34,7 @@ _EXPECTED_SPECFUSE_TREE = {
|
|
|
34
34
|
"templates/GATE.template.md",
|
|
35
35
|
"templates/PLAN.template.md",
|
|
36
36
|
"templates/WU.template.md",
|
|
37
|
+
"rules/close-discipline.md",
|
|
37
38
|
"rules/correlation-ids.md",
|
|
38
39
|
"rules/never-touch.md",
|
|
39
40
|
"rules/planning-discipline.md",
|
|
@@ -117,6 +118,7 @@ class TestInitFullLayout(unittest.TestCase):
|
|
|
117
118
|
|
|
118
119
|
def test_rules_byte_faithful(self):
|
|
119
120
|
for rel in (
|
|
121
|
+
"rules/close-discipline.md",
|
|
120
122
|
"rules/correlation-ids.md",
|
|
121
123
|
"rules/never-touch.md",
|
|
122
124
|
"rules/planning-discipline.md",
|
|
@@ -42,6 +42,14 @@ def _write_plan(feature_dir: Path, gates_yaml: str) -> None:
|
|
|
42
42
|
"```yaml\n"
|
|
43
43
|
f"{gates_yaml}\n"
|
|
44
44
|
"```\n"
|
|
45
|
+
"\n"
|
|
46
|
+
"## Existing-mechanism search\n"
|
|
47
|
+
"\n"
|
|
48
|
+
"n/a — no enforcement or measurement designed\n"
|
|
49
|
+
"\n"
|
|
50
|
+
"## Escalation-predicate satisfiability\n"
|
|
51
|
+
"\n"
|
|
52
|
+
"n/a — no severity flip\n"
|
|
45
53
|
)
|
|
46
54
|
|
|
47
55
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright 2026 Specfuse contributors
|
|
3
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
4
|
+
#
|
|
5
|
+
"""Planning-discipline section-presence lint (#201, second half).
|
|
6
|
+
|
|
7
|
+
WARN-only checks that the sections introduced by the FEAT-2026-0049
|
|
8
|
+
planning-discipline drop (PR #211) are present: PLAN.md carries
|
|
9
|
+
'Existing-mechanism search' and 'Escalation-predicate satisfiability'
|
|
10
|
+
(an explicit n/a line satisfies them — presence, not content), and each
|
|
11
|
+
non-passed gate file carries 'Arming discipline'. Sealed features
|
|
12
|
+
(plan status done/abandoned) are history and never warn.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
from contextlib import redirect_stdout
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from tests._loop_loader import load_lint
|
|
24
|
+
|
|
25
|
+
lint_plan = load_lint()
|
|
26
|
+
|
|
27
|
+
_SECTIONS = (
|
|
28
|
+
"\n## Existing-mechanism search\n\nn/a — no enforcement designed\n"
|
|
29
|
+
"\n## Escalation-predicate satisfiability\n\nn/a — no severity flip\n"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_feature(tmpdir: str, *, plan_status: str = "active",
|
|
34
|
+
plan_sections: str = _SECTIONS,
|
|
35
|
+
gate_status: str = "open",
|
|
36
|
+
gate_body: str = "\n## Arming discipline\n\nprobe notes\n",
|
|
37
|
+
) -> Path:
|
|
38
|
+
feature = Path(tmpdir) / "feature"
|
|
39
|
+
feature.mkdir(exist_ok=True)
|
|
40
|
+
(feature / "PLAN.md").write_text(
|
|
41
|
+
"---\nfeature_id: FEAT-2026-9998\ntitle: T\nbranch: feat/t\n"
|
|
42
|
+
f"roadmap_goal: g\nstatus: {plan_status}\n"
|
|
43
|
+
"planned_cost_usd: 10.00\n---\n\n# Plan\n\n"
|
|
44
|
+
"```yaml\ngates:\n - gate: 1\n file: GATE-01.md\n"
|
|
45
|
+
" work_units:\n - id: FEAT-2026-9998/T01\n"
|
|
46
|
+
" file: WU-01-impl.md\n depends_on: []\n"
|
|
47
|
+
" - id: FEAT-2026-9998/G1-CLOSE\n file: WU-90-close.md\n"
|
|
48
|
+
" depends_on: [FEAT-2026-9998/T01]\n```\n"
|
|
49
|
+
f"{plan_sections}"
|
|
50
|
+
)
|
|
51
|
+
(feature / "GATE-01.md").write_text(
|
|
52
|
+
f"---\ngate: 1\nstatus: {gate_status}\n---\n\n# Gate 1\n{gate_body}"
|
|
53
|
+
)
|
|
54
|
+
for fname, wid, wtype in (("WU-01-impl.md", "T01", "implementation"),
|
|
55
|
+
("WU-90-close.md", "G1-CLOSE", "close")):
|
|
56
|
+
(feature / fname).write_text(
|
|
57
|
+
f"---\nid: FEAT-2026-9998/{wid}\ntype: {wtype}\nstatus: done\n"
|
|
58
|
+
f"attempts: 1\nplanned_cost_usd: 5.0\n---\n\n# {wid}\n"
|
|
59
|
+
)
|
|
60
|
+
return feature
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_lint(feature: Path) -> tuple[list[str], str]:
|
|
64
|
+
buf = io.StringIO()
|
|
65
|
+
with redirect_stdout(buf):
|
|
66
|
+
errs = lint_plan.lint(feature)
|
|
67
|
+
return errs, buf.getvalue()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestPlanningSectionLint(unittest.TestCase):
|
|
71
|
+
|
|
72
|
+
def test_silent_when_sections_present(self):
|
|
73
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
74
|
+
errs, out = _run_lint(_write_feature(tmp))
|
|
75
|
+
self.assertEqual(errs, [])
|
|
76
|
+
self.assertNotIn("Existing-mechanism", out)
|
|
77
|
+
self.assertNotIn("Arming discipline", out)
|
|
78
|
+
|
|
79
|
+
def test_warns_on_missing_plan_sections(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
81
|
+
errs, out = _run_lint(_write_feature(tmp, plan_sections=""))
|
|
82
|
+
self.assertEqual(errs, [], "section presence is WARN-only")
|
|
83
|
+
self.assertIn("missing 'Existing-mechanism search'", out)
|
|
84
|
+
self.assertIn("missing 'Escalation-predicate satisfiability'", out)
|
|
85
|
+
|
|
86
|
+
def test_na_line_satisfies_presence(self):
|
|
87
|
+
"""Presence, not content: the template's explicit n/a escape passes."""
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
_, out = _run_lint(_write_feature(tmp, plan_sections=_SECTIONS))
|
|
90
|
+
self.assertNotIn("missing 'Existing-mechanism search'", out)
|
|
91
|
+
|
|
92
|
+
def test_warns_on_gate_missing_arming_section(self):
|
|
93
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
94
|
+
errs, out = _run_lint(_write_feature(tmp, gate_body=""))
|
|
95
|
+
self.assertEqual(errs, [])
|
|
96
|
+
self.assertIn("missing 'Arming discipline'", out)
|
|
97
|
+
self.assertIn("GATE-01.md", out)
|
|
98
|
+
|
|
99
|
+
def test_passed_gate_never_warns(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
101
|
+
_, out = _run_lint(_write_feature(tmp, gate_status="passed",
|
|
102
|
+
gate_body=""))
|
|
103
|
+
self.assertNotIn("missing 'Arming discipline'", out)
|
|
104
|
+
|
|
105
|
+
def test_sealed_feature_never_warns(self):
|
|
106
|
+
for status in ("done", "abandoned"):
|
|
107
|
+
with self.subTest(status=status):
|
|
108
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
109
|
+
_, out = _run_lint(_write_feature(
|
|
110
|
+
tmp, plan_status=status, plan_sections="",
|
|
111
|
+
gate_body=""))
|
|
112
|
+
self.assertNotIn("missing 'Existing-mechanism search'", out)
|
|
113
|
+
self.assertNotIn("missing 'Arming discipline'", out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|
|
@@ -134,15 +134,20 @@ class TestGateBudgetUsd(unittest.TestCase):
|
|
|
134
134
|
|
|
135
135
|
class TestGateSpentUsd(unittest.TestCase):
|
|
136
136
|
|
|
137
|
-
def
|
|
137
|
+
def test_sums_recorded_cost_regardless_of_status(self):
|
|
138
|
+
"""#219: recorded cost is real spend on every status — a blocked WU's
|
|
139
|
+
re-arm burn must count toward the brake, not wait for done. A WU that
|
|
140
|
+
never dispatched carries no cost fields and contributes 0."""
|
|
138
141
|
with tempfile.TemporaryDirectory() as tmp:
|
|
139
142
|
feature = Path(tmp)
|
|
140
143
|
_write_wu(feature / "WU-T01.md", wu_id="FEAT-2026-9701/T01",
|
|
141
144
|
status="done", cost_usd=0.5)
|
|
145
|
+
# Blocked mid-gate: write_cost_to_wu recorded its spend.
|
|
142
146
|
_write_wu(feature / "WU-T02.md", wu_id="FEAT-2026-9701/T02",
|
|
143
|
-
status="
|
|
147
|
+
status="blocked_human", cost_usd=1.25)
|
|
148
|
+
# Never dispatched: no cost fields at all.
|
|
144
149
|
_write_wu(feature / "WU-T03.md", wu_id="FEAT-2026-9701/T03",
|
|
145
|
-
status="
|
|
150
|
+
status="pending", cost_usd=None)
|
|
146
151
|
gate = {
|
|
147
152
|
"file": "GATE-01.md",
|
|
148
153
|
"work_units": [
|
|
@@ -152,7 +157,26 @@ class TestGateSpentUsd(unittest.TestCase):
|
|
|
152
157
|
],
|
|
153
158
|
}
|
|
154
159
|
spent = loop.gate_spent_usd({}, gate, feature)
|
|
155
|
-
self.assertAlmostEqual(spent, 1.
|
|
160
|
+
self.assertAlmostEqual(spent, 1.75, places=6)
|
|
161
|
+
|
|
162
|
+
def test_blocked_wu_cumulative_counts_toward_brake(self):
|
|
163
|
+
"""The WU-06 shape: blocked with folded prior-cycle spend — the brake
|
|
164
|
+
must see the full lifetime burn while the WU is still blocked."""
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
166
|
+
feature = Path(tmp)
|
|
167
|
+
(feature / "WU-T01.md").write_text(
|
|
168
|
+
"---\nid: FEAT-2026-9701/T01\ntype: implementation\n"
|
|
169
|
+
"model: sonnet\nstatus: blocked_human\nattempts: 3\n"
|
|
170
|
+
"cost_usd: 2.75\ncumulative_cost_usd: 27.54\n---\n\n# T01\n"
|
|
171
|
+
)
|
|
172
|
+
gate = {
|
|
173
|
+
"file": "GATE-01.md",
|
|
174
|
+
"work_units": [
|
|
175
|
+
{"id": "FEAT-2026-9701/T01", "file": "WU-T01.md"},
|
|
176
|
+
],
|
|
177
|
+
}
|
|
178
|
+
self.assertAlmostEqual(
|
|
179
|
+
loop.gate_spent_usd({}, gate, feature), 30.29, places=6)
|
|
156
180
|
|
|
157
181
|
def test_missing_cost_contributes_zero(self):
|
|
158
182
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -91,8 +91,15 @@ class TestUpgradeOverlaysDocs(unittest.TestCase):
|
|
|
91
91
|
)
|
|
92
92
|
|
|
93
93
|
def test_upgrade_prunes_removed_docs(self):
|
|
94
|
+
"""A docs file the seed once shipped (manifest-owned) but no longer
|
|
95
|
+
carries is pruned; manifest membership is the ownership proof (#214)."""
|
|
96
|
+
import json as _json
|
|
94
97
|
stray = self.specfuse / "docs" / "obsolete-doc.md"
|
|
95
98
|
stray.write_bytes(b"old doc")
|
|
99
|
+
manifest = self.specfuse / ".scaffold-manifest"
|
|
100
|
+
entries = _json.loads(manifest.read_text())
|
|
101
|
+
entries["docs/obsolete-doc.md"] = "0" * 64
|
|
102
|
+
manifest.write_text(_json.dumps(entries))
|
|
96
103
|
upgrade_specfuse(self.target)
|
|
97
104
|
self.assertFalse(stray.exists(), "stray docs file must be pruned on upgrade")
|
|
98
105
|
|
|
@@ -16,6 +16,7 @@ _EXPECTED_TREE = {
|
|
|
16
16
|
"templates/GATE.template.md",
|
|
17
17
|
"templates/PLAN.template.md",
|
|
18
18
|
"templates/WU.template.md",
|
|
19
|
+
"rules/close-discipline.md",
|
|
19
20
|
"rules/correlation-ids.md",
|
|
20
21
|
"rules/never-touch.md",
|
|
21
22
|
"rules/planning-discipline.md",
|
|
@@ -45,6 +46,7 @@ _VERSIONED_SEED_TO_TARGET = {
|
|
|
45
46
|
"templates/GATE.template.md": "templates/GATE.template.md",
|
|
46
47
|
"templates/PLAN.template.md": "templates/PLAN.template.md",
|
|
47
48
|
"templates/WU.template.md": "templates/WU.template.md",
|
|
49
|
+
"rules/close-discipline.md": "rules/close-discipline.md",
|
|
48
50
|
"rules/correlation-ids.md": "rules/correlation-ids.md",
|
|
49
51
|
"rules/never-touch.md": "rules/never-touch.md",
|
|
50
52
|
"rules/planning-discipline.md": "rules/planning-discipline.md",
|