@opengsd/gsd-core 1.15.0 → 1.17.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -2
- package/.opencode/plugins/gsd-core.js +336 -6
- package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
- package/GEMINI.md +1 -1
- package/agents/gsd-code-fixer.compact.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.compact.md +5 -3
- package/agents/gsd-code-reviewer.md +8 -6
- package/agents/gsd-debug-session-manager.compact.md +7 -1
- package/agents/gsd-debug-session-manager.md +7 -1
- package/agents/gsd-debugger.md +2 -2
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +4 -4
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +2 -2
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +7 -2
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +21 -30
- package/agents/gsd-ui-auditor.md +42 -51
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +31 -9
- package/bin/gsd-mcp-server.js +3 -1
- package/bin/install.js +487 -290
- package/commands/gsd/add-tests.md +6 -1
- package/commands/gsd/ai-integration-phase.md +6 -1
- package/commands/gsd/audit-fix.md +5 -0
- package/commands/gsd/audit-milestone.md +6 -1
- package/commands/gsd/autonomous.md +5 -0
- package/commands/gsd/capture.md +8 -4
- package/commands/gsd/code-review.md +7 -2
- package/commands/gsd/complete-milestone.md +4 -0
- package/commands/gsd/config.md +7 -3
- package/commands/gsd/debug.md +11 -7
- package/commands/gsd/discuss-phase.md +7 -3
- package/commands/gsd/docs-update.md +12 -7
- package/commands/gsd/eval-review.md +6 -1
- package/commands/gsd/execute-phase.md +12 -7
- package/commands/gsd/extract-learnings.md +5 -0
- package/commands/gsd/fast.md +4 -0
- package/commands/gsd/forensics.md +5 -1
- package/commands/gsd/graphify.md +10 -6
- package/commands/gsd/health.md +5 -0
- package/commands/gsd/help.md +7 -2
- package/commands/gsd/import.md +7 -3
- package/commands/gsd/inbox.md +5 -0
- package/commands/gsd/ingest-docs.md +5 -1
- package/commands/gsd/manager.md +6 -1
- package/commands/gsd/map-codebase.md +7 -3
- package/commands/gsd/mempalace-capture.md +5 -1
- package/commands/gsd/mempalace-recall.md +5 -1
- package/commands/gsd/milestone-summary.md +5 -1
- package/commands/gsd/mvp-phase.md +8 -3
- package/commands/gsd/new-milestone.md +6 -1
- package/commands/gsd/new-project.md +5 -0
- package/commands/gsd/next.md +6 -1
- package/commands/gsd/ns-context.md +4 -0
- package/commands/gsd/ns-ideate.md +4 -0
- package/commands/gsd/ns-manage.md +4 -0
- package/commands/gsd/ns-project.md +4 -0
- package/commands/gsd/ns-review.md +4 -0
- package/commands/gsd/ns-workflow.md +4 -0
- package/commands/gsd/onboard.md +6 -1
- package/commands/gsd/pause-work.md +5 -1
- package/commands/gsd/phase.md +8 -4
- package/commands/gsd/plan-phase.md +6 -1
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/commands/gsd/pr-branch.md +4 -0
- package/commands/gsd/profile-user.md +5 -1
- package/commands/gsd/progress.md +6 -1
- package/commands/gsd/quick-batch.md +20 -8
- package/commands/gsd/quick.md +12 -7
- package/commands/gsd/review.md +5 -1
- package/commands/gsd/secure-phase.md +6 -1
- package/commands/gsd/ship.md +5 -0
- package/commands/gsd/sketch.md +7 -2
- package/commands/gsd/spec-phase.md +5 -1
- package/commands/gsd/spike.md +8 -3
- package/commands/gsd/surface.md +5 -1
- package/commands/gsd/thread.md +4 -0
- package/commands/gsd/ui-phase.md +6 -1
- package/commands/gsd/ui-review.md +6 -1
- package/commands/gsd/ultraplan-phase.md +5 -1
- package/commands/gsd/undo.md +5 -1
- package/commands/gsd/update.md +6 -2
- package/commands/gsd/validate-phase.md +6 -1
- package/commands/gsd/verify-work.md +6 -1
- package/commands/gsd/workspace.md +7 -3
- package/gsd-core/bin/gsd-tools.cjs +159 -58
- package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
- package/gsd-core/bin/lib/adr-parser.cjs +37 -5
- package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
- package/gsd-core/bin/lib/audit.cjs +78 -44
- package/gsd-core/bin/lib/broken-windows.cjs +13 -13
- package/gsd-core/bin/lib/capability-activation.cjs +9 -4
- package/gsd-core/bin/lib/capability-registry.cjs +249 -117
- package/gsd-core/bin/lib/capability-validator.cjs +18 -1
- package/gsd-core/bin/lib/capability-writer.cjs +2 -1
- package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
- package/gsd-core/bin/lib/check-command-router.cjs +164 -1712
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
- package/gsd-core/bin/lib/cli-exit.cjs +12 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +11 -8
- package/gsd-core/bin/lib/command-aliases.cjs +7 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
- package/gsd-core/bin/lib/commands.cjs +201 -178
- package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
- package/gsd-core/bin/lib/config.cjs +43 -12
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/coverage.cjs +4 -8
- package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
- package/gsd-core/bin/lib/drift.cjs +177 -42
- package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
- package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
- package/gsd-core/bin/lib/frontmatter.cjs +413 -234
- package/gsd-core/bin/lib/gap-checker.cjs +72 -29
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
- package/gsd-core/bin/lib/gate-args.cjs +53 -0
- package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
- package/gsd-core/bin/lib/gate-config.cjs +46 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +142 -0
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
- package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
- package/gsd-core/bin/lib/gate-exit.cjs +27 -0
- package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
- package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
- package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
- package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
- package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
- package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
- package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
- package/gsd-core/bin/lib/gate-ui-safety.cjs +81 -0
- package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
- package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
- package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
- package/gsd-core/bin/lib/graphify.cjs +3 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
- package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
- package/gsd-core/bin/lib/init.cjs +194 -108
- package/gsd-core/bin/lib/install-engine.cjs +30 -35
- package/gsd-core/bin/lib/install-profiles.cjs +7 -4
- package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
- package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
- package/gsd-core/bin/lib/io.cjs +122 -3
- package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
- package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
- package/gsd-core/bin/lib/mcp-server.cjs +5 -1
- package/gsd-core/bin/lib/milestone.cjs +19 -1
- package/gsd-core/bin/lib/model-resolver.cjs +18 -18
- package/gsd-core/bin/lib/observability/event.cjs +1 -1
- package/gsd-core/bin/lib/observability/logger.cjs +46 -1
- package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
- package/gsd-core/bin/lib/opencode-version.cjs +486 -0
- package/gsd-core/bin/lib/pattern.cjs +10 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +11 -4
- package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
- package/gsd-core/bin/lib/phase-id.cjs +1 -1
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
- package/gsd-core/bin/lib/phase-status.cjs +360 -0
- package/gsd-core/bin/lib/phase.cjs +301 -80
- package/gsd-core/bin/lib/plan-document.cjs +93 -19
- package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
- package/gsd-core/bin/lib/planning-document.cjs +273 -40
- package/gsd-core/bin/lib/planning-inspect.cjs +42 -8
- package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +76 -53
- package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
- package/gsd-core/bin/lib/profile-output.cjs +6 -3
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +13 -9
- package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
- package/gsd-core/bin/lib/report-parser.cjs +269 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +13 -15
- package/gsd-core/bin/lib/roadmap-parser.cjs +82 -15
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +127 -65
- package/gsd-core/bin/lib/roadmap.cjs +169 -72
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +186 -161
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +13 -10
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +365 -594
- package/gsd-core/bin/lib/runtime-name-policy.cjs +142 -27
- package/gsd-core/bin/lib/runtime-slash.cjs +60 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +37 -2
- package/gsd-core/bin/lib/smart-entry.cjs +111 -56
- package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
- package/gsd-core/bin/lib/state-contract.cjs +15 -18
- package/gsd-core/bin/lib/state-document.cjs +100 -22
- package/gsd-core/bin/lib/state-transition.cjs +67 -9
- package/gsd-core/bin/lib/state.cjs +668 -171
- package/gsd-core/bin/lib/surface.cjs +2 -1
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -152
- package/gsd-core/bin/lib/uat-predicate.cjs +313 -35
- package/gsd-core/bin/lib/uat.cjs +424 -7
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +94 -58
- package/gsd-core/bin/lib/update-context.cjs +18 -1
- package/gsd-core/bin/lib/vendor/README.md +31 -9
- package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
- package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +1265 -324
- package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
- package/gsd-core/bin/lib/verify.cjs +286 -567
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
- package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
- package/gsd-core/bin/lib/workstream.cjs +11 -2
- package/gsd-core/bin/shared/config-defaults.manifest.json +5 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -0
- package/gsd-core/references/autonomous-smart-discuss.md +2 -1
- package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
- package/gsd-core/references/execute-mvp-tdd.md +5 -10
- package/gsd-core/references/execute-phase-between-wave-reset.md +3 -0
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +7 -1
- package/gsd-core/references/offer-next.md +1 -1
- package/gsd-core/references/planning-config.md +1 -1
- package/gsd-core/references/spidr-splitting.md +1 -1
- package/gsd-core/references/tdd.md +10 -4
- package/gsd-core/references/verifier-phase-gates.md +5 -2
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -3
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/UAT.md +17 -1
- package/gsd-core/templates/config.json +2 -11
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +8 -7
- package/gsd-core/workflows/add-tests.md +3 -2
- package/gsd-core/workflows/add-todo.md +4 -3
- package/gsd-core/workflows/ai-integration-phase.md +3 -2
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +4 -3
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +28 -18
- package/gsd-core/workflows/check-todos.md +6 -5
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
- package/gsd-core/workflows/code-review-fix.md +5 -4
- package/gsd-core/workflows/code-review.md +100 -91
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
- package/gsd-core/workflows/complete-milestone.md +12 -7
- package/gsd-core/workflows/debug.md +30 -5
- package/gsd-core/workflows/diagnose-issues.md +3 -2
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +4 -3
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +4 -3
- package/gsd-core/workflows/edit-phase.md +4 -3
- package/gsd-core/workflows/eval-review.md +5 -3
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +4 -2
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
- package/gsd-core/workflows/execute-phase.md +81 -171
- package/gsd-core/workflows/execute-plan.md +13 -19
- package/gsd-core/workflows/explore.md +2 -2
- package/gsd-core/workflows/extract-learnings.md +3 -2
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +2 -1
- package/gsd-core/workflows/import.md +3 -2
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +4 -3
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +5 -3
- package/gsd-core/workflows/map-codebase.md +4 -3
- package/gsd-core/workflows/milestone-summary.md +3 -2
- package/gsd-core/workflows/mvp-phase.md +14 -14
- package/gsd-core/workflows/new-milestone.md +10 -10
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +1 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +2 -2
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +1 -1
- package/gsd-core/workflows/plan-phase.md +27 -18
- package/gsd-core/workflows/plan-review-convergence.md +1 -1
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +19 -49
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -2
- package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +15 -10
- package/gsd-core/workflows/quick.md +42 -31
- package/gsd-core/workflows/remove-phase.md +3 -2
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +17 -12
- package/gsd-core/workflows/review.md +3 -2
- package/gsd-core/workflows/scan.md +3 -2
- package/gsd-core/workflows/secure-phase.md +11 -12
- package/gsd-core/workflows/settings-advanced.md +1 -1
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +12 -12
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +1 -1
- package/gsd-core/workflows/thread.md +2 -2
- package/gsd-core/workflows/transition.md +13 -23
- package/gsd-core/workflows/ui-phase.md +5 -4
- package/gsd-core/workflows/ui-review.md +4 -3
- package/gsd-core/workflows/ultraplan-phase.md +3 -2
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/validate-phase.md +10 -12
- package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
- package/gsd-core/workflows/verify-work.md +68 -59
- package/hooks/dist/gsd-agent-isolation-guard.js +8 -0
- package/hooks/dist/gsd-check-update-worker.js +8 -0
- package/hooks/dist/gsd-check-update.js +34 -20
- package/hooks/dist/gsd-context-monitor.js +45 -13
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
- package/hooks/dist/gsd-secret-read-guard.js +263 -16
- package/hooks/dist/gsd-statusline.js +336 -29
- package/hooks/dist/gsd-update-banner.js +8 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
- package/hooks/dist/gsd-workflow-guard.js +5 -4
- package/hooks/dist/gsd-worktree-path-guard.js +6 -2
- package/hooks/dist/lib/cli-exit.js +12 -0
- package/hooks/dist/lib/git-probe.js +17 -1
- package/hooks/dist/lib/isolation-sentinel.js +2 -2
- package/hooks/gsd-agent-isolation-guard.js +8 -0
- package/hooks/gsd-check-update-worker.js +8 -0
- package/hooks/gsd-check-update.js +34 -20
- package/hooks/gsd-context-monitor.js +45 -13
- package/hooks/gsd-cursor-subagent-start.js +8 -0
- package/hooks/gsd-secret-read-guard.js +263 -16
- package/hooks/gsd-statusline.js +336 -29
- package/hooks/gsd-update-banner.js +8 -0
- package/hooks/gsd-windsurf-pre-write.js +11 -2
- package/hooks/gsd-workflow-guard.js +5 -4
- package/hooks/gsd-worktree-path-guard.js +6 -2
- package/hooks/hooks.json +5 -5
- package/hooks/lib/cli-exit.js +12 -0
- package/hooks/lib/git-probe.js +17 -1
- package/hooks/lib/isolation-sentinel.js +2 -2
- package/package.json +21 -4
- package/scripts/changeset/parse.cjs +52 -4
- package/scripts/ci-timeout-report.cjs +770 -4
- package/scripts/command-contract-helpers.cjs +12 -8
- package/scripts/docs-guard-registry.cjs +6 -0
- package/scripts/gen-capability-registry.cjs +7 -1
- package/scripts/gen-features.cjs +13 -8
- package/scripts/gen-hooks-cli-exit.cjs +12 -28
- package/scripts/gen-loop-host-contract.cjs +14 -2
- package/scripts/gen-platform-conformance-tier.cjs +187 -1
- package/scripts/gen-plugin-skills.cjs +87 -1
- package/scripts/gen-research-agents.cjs +24 -31
- package/scripts/gen-scripts-cli-exit.cjs +30 -3
- package/scripts/gen-test-timings.cjs +32 -7
- package/scripts/lib/cli-exit.cjs +12 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +24 -2
- package/scripts/lib/ndjson-reporter.cjs +28 -3
- package/scripts/lib/platform-conformance-tier.generated.cjs +44 -5
- package/scripts/lib/registration-ledger-preload.cjs +155 -0
- package/scripts/lib/vendor-bundle.cjs +59 -0
- package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-completion-predicate-drift.cjs +18 -19
- package/scripts/lint-descriptions.cjs +7 -3
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +10 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
- package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
- package/scripts/lint-phase-enumeration-drift.cjs +12 -8
- package/scripts/lint-phase-id-drift.cjs +29 -0
- package/scripts/lint-planning-document-positive-control.cjs +329 -0
- package/scripts/lint-response-language-coverage.cjs +3 -0
- package/scripts/lint-skill-deps.cjs +7 -3
- package/scripts/lint-test-file-count.allowlist.json +11 -1
- package/scripts/lint-test-file-count.cjs +34 -1
- package/scripts/lint-vendored-deps.cjs +41 -5
- package/scripts/lint-workflow-shellcheck-baseline.json +10 -10
- package/scripts/mutation-matrix.cjs +53 -6
- package/scripts/release-tarball-smoke.cjs +14 -7
- package/scripts/require-issue-link-policy.cjs +6 -2
- package/scripts/sync-runtime-launcher.cjs +184 -2
- package/scripts/verify-npm-publish.cjs +76 -20
- package/skills/gsd-add-tests/SKILL.md +6 -1
- package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
- package/skills/gsd-audit-fix/SKILL.md +5 -0
- package/skills/gsd-audit-milestone/SKILL.md +6 -1
- package/skills/gsd-autonomous/SKILL.md +5 -0
- package/skills/gsd-capture/SKILL.md +8 -4
- package/skills/gsd-code-review/SKILL.md +7 -2
- package/skills/gsd-complete-milestone/SKILL.md +4 -0
- package/skills/gsd-config/SKILL.md +8 -4
- package/skills/gsd-debug/SKILL.md +11 -7
- package/skills/gsd-discuss-phase/SKILL.md +7 -3
- package/skills/gsd-docs-update/SKILL.md +12 -7
- package/skills/gsd-eval-review/SKILL.md +6 -1
- package/skills/gsd-execute-phase/SKILL.md +12 -7
- package/skills/gsd-extract-learnings/SKILL.md +5 -0
- package/skills/gsd-fast/SKILL.md +4 -0
- package/skills/gsd-forensics/SKILL.md +5 -1
- package/skills/gsd-graphify/SKILL.md +10 -6
- package/skills/gsd-health/SKILL.md +5 -0
- package/skills/gsd-help/SKILL.md +7 -2
- package/skills/gsd-import/SKILL.md +7 -3
- package/skills/gsd-inbox/SKILL.md +5 -0
- package/skills/gsd-ingest-docs/SKILL.md +5 -1
- package/skills/gsd-manager/SKILL.md +6 -1
- package/skills/gsd-map-codebase/SKILL.md +7 -3
- package/skills/gsd-mempalace-capture/SKILL.md +5 -1
- package/skills/gsd-mempalace-recall/SKILL.md +5 -1
- package/skills/gsd-milestone-summary/SKILL.md +5 -1
- package/skills/gsd-mvp-phase/SKILL.md +8 -3
- package/skills/gsd-new-milestone/SKILL.md +6 -1
- package/skills/gsd-new-project/SKILL.md +5 -0
- package/skills/gsd-next/SKILL.md +6 -1
- package/skills/gsd-ns-context/SKILL.md +4 -0
- package/skills/gsd-ns-ideate/SKILL.md +4 -0
- package/skills/gsd-ns-manage/SKILL.md +4 -0
- package/skills/gsd-ns-project/SKILL.md +4 -0
- package/skills/gsd-ns-review/SKILL.md +4 -0
- package/skills/gsd-ns-workflow/SKILL.md +4 -0
- package/skills/gsd-onboard/SKILL.md +6 -1
- package/skills/gsd-pause-work/SKILL.md +5 -1
- package/skills/gsd-phase/SKILL.md +8 -4
- package/skills/gsd-plan-phase/SKILL.md +6 -1
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/skills/gsd-pr-branch/SKILL.md +4 -0
- package/skills/gsd-profile-user/SKILL.md +5 -1
- package/skills/gsd-progress/SKILL.md +6 -1
- package/skills/gsd-quick/SKILL.md +16 -10
- package/skills/gsd-quick-batch/SKILL.md +20 -8
- package/skills/gsd-review/SKILL.md +5 -1
- package/skills/gsd-review-backlog/SKILL.md +3 -2
- package/skills/gsd-secure-phase/SKILL.md +6 -1
- package/skills/gsd-ship/SKILL.md +5 -0
- package/skills/gsd-sketch/SKILL.md +7 -2
- package/skills/gsd-spec-phase/SKILL.md +5 -1
- package/skills/gsd-spike/SKILL.md +8 -3
- package/skills/gsd-surface/SKILL.md +5 -1
- package/skills/gsd-thread/SKILL.md +4 -0
- package/skills/gsd-ui-phase/SKILL.md +6 -1
- package/skills/gsd-ui-review/SKILL.md +6 -1
- package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
- package/skills/gsd-undo/SKILL.md +5 -1
- package/skills/gsd-update/SKILL.md +6 -2
- package/skills/gsd-validate-phase/SKILL.md +6 -1
- package/skills/gsd-verify-work/SKILL.md +6 -1
- package/skills/gsd-workspace/SKILL.md +7 -3
- package/skills/gsd-workstreams/SKILL.md +6 -6
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +0 -24
|
@@ -32,7 +32,7 @@ bracketMilestoneIntroSrcFor, } = phaseIdMod;
|
|
|
32
32
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
33
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
34
34
|
// #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
|
|
35
|
-
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection } = roadmapParserMod;
|
|
35
|
+
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection, classifyMilestoneScalar } = roadmapParserMod;
|
|
36
36
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
37
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
38
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
@@ -60,7 +60,7 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
60
60
|
const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
|
|
61
61
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
62
|
const verificationMod = require("./verification.cjs");
|
|
63
|
-
const { isPhaseComplete } = verificationMod;
|
|
63
|
+
const { isPhaseComplete, VerificationStatusError } = verificationMod;
|
|
64
64
|
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
|
|
65
65
|
// (phase-lifecycle.cts) — reused for the completed-phases numerator floor so
|
|
66
66
|
// this scan cannot grow a second ROADMAP parser. Pure computation module (no
|
|
@@ -1080,7 +1080,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
|
|
|
1080
1080
|
const existingFm = extractFrontmatter(preContent, statePath);
|
|
1081
1081
|
const preBody = stripFrontmatter(preContent);
|
|
1082
1082
|
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
1083
|
-
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
1083
|
+
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm), isMilestoneExplicitlyNull(existingFm));
|
|
1084
1084
|
const progress = builtFm['progress'];
|
|
1085
1085
|
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
|
1086
1086
|
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
|
@@ -2392,6 +2392,112 @@ function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyCon
|
|
|
2392
2392
|
}
|
|
2393
2393
|
return count;
|
|
2394
2394
|
}
|
|
2395
|
+
/**
|
|
2396
|
+
* The phase directories every STATE.md frontmatter rebuild scans, and the
|
|
2397
|
+
* ROADMAP facts derived alongside them — the ONE owner of that set (#5118).
|
|
2398
|
+
*
|
|
2399
|
+
* `phaseDirs` is milestone-scoped (`listMilestonePhaseDirs`), drops retired
|
|
2400
|
+
* phases (#1514) and holds exactly one directory per normalized phase key
|
|
2401
|
+
* (Bug #2445, deterministic tie-break #3355). `buildStateFrontmatter` counts
|
|
2402
|
+
* from it, and `statePhaseDirsToScan` hands it to `phase remove`'s pre-write
|
|
2403
|
+
* validation, so the two cannot disagree about which reports a rebuild reads.
|
|
2404
|
+
*/
|
|
2405
|
+
function scanStatePhaseDirs(cwd, phasesDir, phaseConvention, storedMilestone,
|
|
2406
|
+
// `quiet` suppresses ONLY the duplicate-phase-key stderr warning. A pre-write
|
|
2407
|
+
// validation pass that is followed by the real rebuild scan sets it, so the
|
|
2408
|
+
// one command prints the warning once (the rebuild scan owns the warning).
|
|
2409
|
+
options = {}) {
|
|
2410
|
+
// Read the current-milestone ROADMAP scope once: it feeds both the
|
|
2411
|
+
// heading-based phase count and the retired/folded-phase
|
|
2412
|
+
// exclusion (#1514). Computed before the disk scan so retired phases
|
|
2413
|
+
// can be dropped from the dir set too.
|
|
2414
|
+
let roadmapScope = null;
|
|
2415
|
+
let roadmapRaw = null;
|
|
2416
|
+
let retiredPhaseNums = new Set();
|
|
2417
|
+
try {
|
|
2418
|
+
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
2419
|
+
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
2420
|
+
if (roadmapRaw !== null) {
|
|
2421
|
+
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
2422
|
+
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
2426
|
+
// #3017: scope the milestone filter to the STORED milestone when available,
|
|
2427
|
+
// so a state.* write doesn't auto-derive (and mis-bind) to a different
|
|
2428
|
+
// milestone's heading and clobber the stored value + progress counts.
|
|
2429
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
2430
|
+
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
2431
|
+
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
2432
|
+
// (which also never excluded sentinels, unlike the owner).
|
|
2433
|
+
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2434
|
+
cwd,
|
|
2435
|
+
versionOverride: storedMilestone ?? null,
|
|
2436
|
+
phaseIdConvention: phaseConvention,
|
|
2437
|
+
});
|
|
2438
|
+
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
2439
|
+
// .planning/phases/ alongside new dirs with the same phase number,
|
|
2440
|
+
// de-duplicate by normalized phase number keeping exactly one dir
|
|
2441
|
+
// per key (deterministic tie-break: see #3355 below). This prevents
|
|
2442
|
+
// double-counting (e.g. two "Phase 1" dirs).
|
|
2443
|
+
const seenPhaseNums = new Map(); // normalizedNum -> dirName
|
|
2444
|
+
for (const dir of allMatchingDirs) {
|
|
2445
|
+
// #1514: a retired/folded phase keeps a directory but no completion
|
|
2446
|
+
// artifact; drop it from the disk phase set so it counts toward
|
|
2447
|
+
// neither the denominator nor the numerator (mirrors the heading
|
|
2448
|
+
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
2449
|
+
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
2450
|
+
continue;
|
|
2451
|
+
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2452
|
+
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
2453
|
+
// diverged from extractPhaseToken/phaseKeyFromDir on
|
|
2454
|
+
// project-code-prefixed dirs (whole dirname fell through as the key,
|
|
2455
|
+
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2456
|
+
// multi-segment milestone dirs. Same key surface used two lines
|
|
2457
|
+
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2458
|
+
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
2459
|
+
if (!seenPhaseNums.has(key)) {
|
|
2460
|
+
seenPhaseNums.set(key, dir);
|
|
2461
|
+
}
|
|
2462
|
+
else {
|
|
2463
|
+
// #3355: the survivor of a same-milestone collision must be
|
|
2464
|
+
// chosen from repository CONTENT, never from filesystem state.
|
|
2465
|
+
// The pre-#3355 tie-break was `mtimeMs` — a checkout-order
|
|
2466
|
+
// signal — so two byte-identical checkouts of the same commit
|
|
2467
|
+
// that wrote the colliding dirs in a different order picked
|
|
2468
|
+
// different survivors, and progress.total_plans /
|
|
2469
|
+
// completed_plans drifted across clones and CI runs. The
|
|
2470
|
+
// directory NAME is git-tracked content and a total order, so
|
|
2471
|
+
// the lexicographically-first dir wins deterministically. The
|
|
2472
|
+
// collision is still a project-level defect (duplicate phase
|
|
2473
|
+
// number in scope), so it is surfaced on stderr instead of
|
|
2474
|
+
// being silently resolved. The Bug #2445 invariant — exactly
|
|
2475
|
+
// one survivor per normalized phase number — is unchanged.
|
|
2476
|
+
const incumbent = seenPhaseNums.get(key);
|
|
2477
|
+
const survivor = dir < incumbent ? dir : incumbent;
|
|
2478
|
+
seenPhaseNums.set(key, survivor);
|
|
2479
|
+
if (options.quiet !== true)
|
|
2480
|
+
process.stderr.write(`gsd: warning — phase directories '${incumbent}' and '${dir}' both normalize to phase key '${key}' (duplicate phase number in .planning/phases/); keeping '${survivor}' by deterministic lexicographic order. (#3355)\n`);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return {
|
|
2484
|
+
phaseDirs: [...seenPhaseNums.values()],
|
|
2485
|
+
phaseDirScope,
|
|
2486
|
+
roadmapScope,
|
|
2487
|
+
roadmapRaw,
|
|
2488
|
+
retiredPhaseNums,
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* #5038: does existing frontmatter assert an explicit null milestone, as
|
|
2493
|
+
* opposed to omitting the `milestone:` key? `extractFrontmatter` parses under
|
|
2494
|
+
* FAILSAFE_SCHEMA, so YAML null arrives as a string; classification is
|
|
2495
|
+
* delegated to classifyMilestoneScalar.
|
|
2496
|
+
*/
|
|
2497
|
+
function isMilestoneExplicitlyNull(existingFm) {
|
|
2498
|
+
const raw = existingFm['milestone'];
|
|
2499
|
+
return typeof raw === 'string' && classifyMilestoneScalar(raw).explicitNull;
|
|
2500
|
+
}
|
|
2395
2501
|
/**
|
|
2396
2502
|
* Extract machine-readable fields from STATE.md markdown body and build
|
|
2397
2503
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
@@ -2403,7 +2509,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2403
2509
|
// #3354/#3573 withhold condition the disk scan returns null for all four
|
|
2404
2510
|
// counters, and these stored values are what the progress block falls back
|
|
2405
2511
|
// to (else the keys are omitted).
|
|
2406
|
-
storedCompletedPhases, storedTotalPlans, storedCompletedPlans
|
|
2512
|
+
storedCompletedPhases, storedTotalPlans, storedCompletedPlans,
|
|
2513
|
+
// #5038: explicit `milestone: null` (vs an absent key); withholds progress
|
|
2514
|
+
// counters on a sectioned ROADMAP without the unbound warning.
|
|
2515
|
+
milestoneExplicitlyNull) {
|
|
2407
2516
|
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
|
2408
2517
|
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
|
2409
2518
|
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
|
@@ -2505,79 +2614,12 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2505
2614
|
// on repeated buildStateFrontmatter invocations within the same process (#1967)
|
|
2506
2615
|
let cached = _diskScanCache.get(cwd);
|
|
2507
2616
|
if (!cached) {
|
|
2508
|
-
//
|
|
2509
|
-
//
|
|
2510
|
-
//
|
|
2511
|
-
//
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
let retiredPhaseNums = new Set();
|
|
2515
|
-
try {
|
|
2516
|
-
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
2517
|
-
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
2518
|
-
if (roadmapRaw !== null) {
|
|
2519
|
-
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
2520
|
-
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
2523
|
-
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
2524
|
-
// #3017: scope the milestone filter to the STORED milestone when available,
|
|
2525
|
-
// so a state.* write doesn't auto-derive (and mis-bind) to a different
|
|
2526
|
-
// milestone's heading and clobber the stored value + progress counts.
|
|
2527
|
-
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
2528
|
-
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
2529
|
-
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
2530
|
-
// (which also never excluded sentinels, unlike the owner).
|
|
2531
|
-
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2532
|
-
cwd,
|
|
2533
|
-
versionOverride: storedMilestone ?? null,
|
|
2534
|
-
phaseIdConvention: phaseConvention,
|
|
2535
|
-
});
|
|
2536
|
-
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
2537
|
-
// .planning/phases/ alongside new dirs with the same phase number,
|
|
2538
|
-
// de-duplicate by normalized phase number keeping exactly one dir
|
|
2539
|
-
// per key (deterministic tie-break: see #3355 below). This prevents
|
|
2540
|
-
// double-counting (e.g. two "Phase 1" dirs).
|
|
2541
|
-
const seenPhaseNums = new Map(); // normalizedNum -> dirName
|
|
2542
|
-
for (const dir of allMatchingDirs) {
|
|
2543
|
-
// #1514: a retired/folded phase keeps a directory but no completion
|
|
2544
|
-
// artifact; drop it from the disk phase set so it counts toward
|
|
2545
|
-
// neither the denominator nor the numerator (mirrors the heading
|
|
2546
|
-
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
2547
|
-
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
2548
|
-
continue;
|
|
2549
|
-
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2550
|
-
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
2551
|
-
// diverged from extractPhaseToken/phaseKeyFromDir on
|
|
2552
|
-
// project-code-prefixed dirs (whole dirname fell through as the key,
|
|
2553
|
-
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2554
|
-
// multi-segment milestone dirs. Same key surface used two lines
|
|
2555
|
-
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2556
|
-
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
2557
|
-
if (!seenPhaseNums.has(key)) {
|
|
2558
|
-
seenPhaseNums.set(key, dir);
|
|
2559
|
-
}
|
|
2560
|
-
else {
|
|
2561
|
-
// #3355: the survivor of a same-milestone collision must be
|
|
2562
|
-
// chosen from repository CONTENT, never from filesystem state.
|
|
2563
|
-
// The pre-#3355 tie-break was `mtimeMs` — a checkout-order
|
|
2564
|
-
// signal — so two byte-identical checkouts of the same commit
|
|
2565
|
-
// that wrote the colliding dirs in a different order picked
|
|
2566
|
-
// different survivors, and progress.total_plans /
|
|
2567
|
-
// completed_plans drifted across clones and CI runs. The
|
|
2568
|
-
// directory NAME is git-tracked content and a total order, so
|
|
2569
|
-
// the lexicographically-first dir wins deterministically. The
|
|
2570
|
-
// collision is still a project-level defect (duplicate phase
|
|
2571
|
-
// number in scope), so it is surfaced on stderr instead of
|
|
2572
|
-
// being silently resolved. The Bug #2445 invariant — exactly
|
|
2573
|
-
// one survivor per normalized phase number — is unchanged.
|
|
2574
|
-
const incumbent = seenPhaseNums.get(key);
|
|
2575
|
-
const survivor = dir < incumbent ? dir : incumbent;
|
|
2576
|
-
seenPhaseNums.set(key, survivor);
|
|
2577
|
-
process.stderr.write(`gsd: warning — phase directories '${incumbent}' and '${dir}' both normalize to phase key '${key}' (duplicate phase number in .planning/phases/); keeping '${survivor}' by deterministic lexicographic order. (#3355)\n`);
|
|
2578
|
-
}
|
|
2579
|
-
}
|
|
2580
|
-
const phaseDirs = [...seenPhaseNums.values()];
|
|
2617
|
+
// The ROADMAP scope + the deduped, milestone-scoped disk phase set:
|
|
2618
|
+
// one owner (scanStatePhaseDirs, above buildStateFrontmatter) so the
|
|
2619
|
+
// #5118 pre-write validation in `phase remove` reads exactly this set.
|
|
2620
|
+
// #5038: an explicit-null `storedMilestone` is text, not a version;
|
|
2621
|
+
// pass a real null so it never reaches the phase-dir version filter.
|
|
2622
|
+
const { phaseDirs, phaseDirScope, roadmapScope, roadmapRaw, retiredPhaseNums, } = scanStatePhaseDirs(cwd, phasesDir, phaseConvention, milestoneExplicitlyNull ? null : storedMilestone);
|
|
2581
2623
|
let diskTotalPlans = 0;
|
|
2582
2624
|
let diskTotalSummaries = 0;
|
|
2583
2625
|
let diskCompletedPhases = 0;
|
|
@@ -2597,7 +2639,15 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2597
2639
|
// #612: `phaseConvention` threaded so a bracket phase dir resolves
|
|
2598
2640
|
// and scopes its verification report like its legacy twin — the
|
|
2599
2641
|
// read-side half of the same thread cmdStateSync gets below.
|
|
2600
|
-
|
|
2642
|
+
const completion = isPhaseComplete(phaseDir, { convention: phaseConvention });
|
|
2643
|
+
// #5118 (no write before the error): every STATE.md write rebuilds
|
|
2644
|
+
// this frontmatter BEFORE its write, so a report whose `status` is
|
|
2645
|
+
// outside the closed set fails the write here — the owner's error,
|
|
2646
|
+
// rethrown past the best-effort catch below, nothing persisted and
|
|
2647
|
+
// nothing cached.
|
|
2648
|
+
if (completion.value.statusError)
|
|
2649
|
+
throw completion.value.statusError;
|
|
2650
|
+
if (completion.value.complete)
|
|
2601
2651
|
diskCompletedPhases++;
|
|
2602
2652
|
}
|
|
2603
2653
|
// Count phase headings from ROADMAP — single source of truth for
|
|
@@ -2618,6 +2668,21 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2618
2668
|
// phase-dir count only, and mark unbounded so percent is skipped
|
|
2619
2669
|
// downstream (mirrors the sync write-path guard).
|
|
2620
2670
|
let milestoneBounded = true;
|
|
2671
|
+
// #2828/#3642: distinguish a FLAT unmilestoned roadmap (no milestone
|
|
2672
|
+
// sectioning at all — only Phase headings) from a MILESTONED one
|
|
2673
|
+
// (milestone/version headings exist). Computed before the
|
|
2674
|
+
// boundedness gate so the explicit-null branch can use it (#5038).
|
|
2675
|
+
// #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
|
|
2676
|
+
// not the >=2 predicate. >=2 under-answers the question this branch
|
|
2677
|
+
// asks: with EXACTLY ONE milestone section and an asserted milestone
|
|
2678
|
+
// absent from the ROADMAP, >=2 read "flat" and the whole-document
|
|
2679
|
+
// count — which IS that single section's phases — was written as the
|
|
2680
|
+
// asserted milestone's total, silently clobbering the stored value.
|
|
2681
|
+
// The >=2 threshold governs SIBLING conflation; asserted-vs-section
|
|
2682
|
+
// needs only one section to go wrong. Zero sections (genuinely flat)
|
|
2683
|
+
// keeps the whole-document count, per #2828.
|
|
2684
|
+
const roadmapHasAnyMilestoneSection = roadmapRaw !== null
|
|
2685
|
+
&& hasAnyMilestoneSection(roadmapRaw);
|
|
2621
2686
|
// #3216 fix (#1761 regression): use `assertedMilestoneVersion` —
|
|
2622
2687
|
// the version STATE.md actually asserts — not the scope-gated
|
|
2623
2688
|
// `milestone`. `milestone` is null on any non-COMPLETE identity
|
|
@@ -2635,27 +2700,19 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2635
2700
|
// defect, design row 17).
|
|
2636
2701
|
milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
|
|
2637
2702
|
}
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2703
|
+
else if (milestoneExplicitlyNull && roadmapRaw !== null && roadmapHasAnyMilestoneSection) {
|
|
2704
|
+
// #5038: explicit null on a sectioned ROADMAP: no section owns
|
|
2705
|
+
// the counts, so treat as unbound for the withhold gate (the
|
|
2706
|
+
// warning below is skipped, null is deliberate).
|
|
2707
|
+
milestoneBounded = false;
|
|
2708
|
+
}
|
|
2709
|
+
// #2828: on a flat roadmap the whole-doc count is correct (no
|
|
2710
|
+
// sibling milestones to conflate); on a sectioned-but-unbounded one
|
|
2711
|
+
// it conflates siblings (#1761), so fall back to phaseDirs.length.
|
|
2644
2712
|
// #3184: routed through the single owner (roadmap-parser.cjs) —
|
|
2645
2713
|
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
|
|
2646
2714
|
// version-token requirement); see hasMilestoneSectioning's own
|
|
2647
2715
|
// doc comment for why that distinction is load-bearing.
|
|
2648
|
-
// #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
|
|
2649
|
-
// not the >=2 predicate. >=2 under-answers the question this branch
|
|
2650
|
-
// asks: with EXACTLY ONE milestone section and an asserted milestone
|
|
2651
|
-
// absent from the ROADMAP, >=2 read "flat" and the whole-document
|
|
2652
|
-
// count — which IS that single section's phases — was written as the
|
|
2653
|
-
// asserted milestone's total, silently clobbering the stored value.
|
|
2654
|
-
// The >=2 threshold governs SIBLING conflation; asserted-vs-section
|
|
2655
|
-
// needs only one section to go wrong. Zero sections (genuinely flat)
|
|
2656
|
-
// keeps the whole-document count, per #2828.
|
|
2657
|
-
const roadmapHasAnyMilestoneSection = roadmapRaw !== null
|
|
2658
|
-
&& hasAnyMilestoneSection(roadmapRaw);
|
|
2659
2716
|
const safeToUseRoadmapCount = milestoneBounded
|
|
2660
2717
|
|| (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
|
|
2661
2718
|
// #3354: the milestoned-but-unbounded sibling of the #2828/#3204
|
|
@@ -2673,7 +2730,8 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2673
2730
|
// The degenerate un-sectioned zero-heading case keeps the
|
|
2674
2731
|
// phaseDirs.length fallback — with nothing declared anywhere else,
|
|
2675
2732
|
// the disk count is the only source and remains correct.
|
|
2676
|
-
|
|
2733
|
+
// #5038: explicit null is withheld silently (explicitNullWithheld).
|
|
2734
|
+
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection && !milestoneExplicitlyNull;
|
|
2677
2735
|
if (milestonedButUnbounded) {
|
|
2678
2736
|
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3354/#3642/#4094)\n`);
|
|
2679
2737
|
}
|
|
@@ -2688,9 +2746,11 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2688
2746
|
// value instead. Without an asserted milestone (fresh project,
|
|
2689
2747
|
// pre-roadmap) the disk count is still the only source and stays
|
|
2690
2748
|
// authoritative (the #3354 doctrine's degenerate case).
|
|
2749
|
+
// #5038: an explicit null is not an asserted milestone.
|
|
2691
2750
|
const roadmapAbsentWithAssertedMilestone = roadmapRaw === null &&
|
|
2692
2751
|
typeof storedMilestone === 'string' &&
|
|
2693
|
-
storedMilestone.trim() !== ''
|
|
2752
|
+
storedMilestone.trim() !== '' &&
|
|
2753
|
+
!milestoneExplicitlyNull;
|
|
2694
2754
|
if (roadmapAbsentWithAssertedMilestone) {
|
|
2695
2755
|
process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3573) (#4094)\n`);
|
|
2696
2756
|
}
|
|
@@ -2703,7 +2763,9 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2703
2763
|
// Pre-#4094 only totalPhases was nulled here, so every resyncing
|
|
2704
2764
|
// write silently clobbered the three stored siblings with the
|
|
2705
2765
|
// under-scoped disk numbers.
|
|
2706
|
-
|
|
2766
|
+
// #5038: silent sibling of milestonedButUnbounded.
|
|
2767
|
+
const explicitNullWithheld = milestoneExplicitlyNull && !milestoneBounded && roadmapHasAnyMilestoneSection;
|
|
2768
|
+
const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone || explicitNullWithheld;
|
|
2707
2769
|
// #4129: floor the completed-phases numerator at the ROADMAP's own
|
|
2708
2770
|
// milestone Complete-row count. The disk numerator counts ONLY
|
|
2709
2771
|
// phase dirs whose *-VERIFICATION.md routes `passed` (isPhaseComplete,
|
|
@@ -2796,9 +2858,13 @@ storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
|
2796
2858
|
* crash `state show`; on failure this simply keeps whatever
|
|
2797
2859
|
* frontmatter-derived totals/completedPhases/etc. were already set
|
|
2798
2860
|
* above, a graceful degrade rather than a corrupted write (nothing is
|
|
2799
|
-
* persisted from this block).
|
|
2861
|
+
* persisted from this block). #5118: an out-of-set verification
|
|
2862
|
+
* report status is not a read failure to degrade over — rethrown. */
|
|
2863
|
+
}
|
|
2864
|
+
catch (err) {
|
|
2865
|
+
if (err instanceof VerificationStatusError)
|
|
2866
|
+
throw err;
|
|
2800
2867
|
}
|
|
2801
|
-
catch { /* intentionally empty */ }
|
|
2802
2868
|
}
|
|
2803
2869
|
// Derive percent from disk counts when available (ground truth).
|
|
2804
2870
|
// Uses min(plan_fraction, phase_fraction) via computeProgressPercent so that
|
|
@@ -3215,7 +3281,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
3215
3281
|
// milestoned-but-unbounded withhold can preserve it across the write
|
|
3216
3282
|
// (the derived progress sub-block replaces the stored one wholesale below,
|
|
3217
3283
|
// so an omitted key would otherwise DELETE the stored value).
|
|
3218
|
-
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
3284
|
+
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm), isMilestoneExplicitlyNull(existingFm));
|
|
3219
3285
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
3220
3286
|
// This prevents a missing Status: field in the body from overwriting a
|
|
3221
3287
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -4447,7 +4513,98 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
|
|
|
4447
4513
|
* (#3818's own direction), which is why keeping it as the candidate
|
|
4448
4514
|
* source was rejected (design doc, Rejected #1).
|
|
4449
4515
|
*/
|
|
4450
|
-
|
|
4516
|
+
/**
|
|
4517
|
+
* ADR-4629 §8.2 (epic #4629, C2): the value a reported/asserted target
|
|
4518
|
+
* resolves to in one document side. Lifted verbatim out of
|
|
4519
|
+
* `reconcileReportedFields` (it was the private `valueOf` closure) so the C2
|
|
4520
|
+
* verifying executor reads a target through the SAME resolution chain the
|
|
4521
|
+
* measured-delta reporter uses — ADR-4629 Decision 4: extend the reporter,
|
|
4522
|
+
* never build a second one.
|
|
4523
|
+
*
|
|
4524
|
+
* #3471 review (unchanged): body-FIRST, frontmatter-key-FLAT-fallback,
|
|
4525
|
+
* dotted-PATH-fallback last. Body-first mirrors the actual write precedence
|
|
4526
|
+
* `patchCore`/`updateCore` apply (#1162's fix — a lowercase body label that
|
|
4527
|
+
* happens to case-exact-match a frontmatter key must still resolve against
|
|
4528
|
+
* the body). `field` a literal flat key (even one containing a `.`) is tried
|
|
4529
|
+
* before it is split and walked as a dotted path (test matrix row 26) —
|
|
4530
|
+
* `resolveFrontmatterPath` pins that same order for the frontmatter side.
|
|
4531
|
+
*
|
|
4532
|
+
* `Current Position` is special-cased: it names the WHOLE `## Current
|
|
4533
|
+
* Position` section, not a single `Label: value` line, so `stateExtractField`
|
|
4534
|
+
* can never resolve it (the root cause of the "Current Position undercount" —
|
|
4535
|
+
* a transform could correctly push `'Current Position'` into its own
|
|
4536
|
+
* `updated` list and it was still silently dropped, because both sides
|
|
4537
|
+
* resolved to `null` and `null === null` failed the old `intended !== null`
|
|
4538
|
+
* guard). `sliceCurrentPositionSection` is the existing fence-aware section
|
|
4539
|
+
* locator (state-transition.cts) — reused rather than re-derived.
|
|
4540
|
+
*
|
|
4541
|
+
* C2 generalizes that special case: a name in `sectionTargets` (a declared
|
|
4542
|
+
* `target: 'section'` assertion) resolves to its whole section body through
|
|
4543
|
+
* the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
|
|
4544
|
+
* Existing callers pass no `sectionTargets` and resolve exactly as before.
|
|
4545
|
+
*/
|
|
4546
|
+
function readStateTarget(fm, body, field, sectionTargets) {
|
|
4547
|
+
if (field === 'Current Position') {
|
|
4548
|
+
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4549
|
+
return section !== null ? section.trim() : null;
|
|
4550
|
+
}
|
|
4551
|
+
if (sectionTargets.has(field)) {
|
|
4552
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
|
|
4553
|
+
return section !== null ? section.body.trim() : null;
|
|
4554
|
+
}
|
|
4555
|
+
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
4556
|
+
if (bodyValue !== null)
|
|
4557
|
+
return bodyValue;
|
|
4558
|
+
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
4559
|
+
return String(fm[field]);
|
|
4560
|
+
if (field.includes('.')) {
|
|
4561
|
+
const resolved = resolveFrontmatterPath(fm, field);
|
|
4562
|
+
if (resolved !== STATE_FIELD_ABSENT) {
|
|
4563
|
+
return stateScalarString(resolved);
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
return null;
|
|
4567
|
+
}
|
|
4568
|
+
const NO_SECTION_TARGETS = new Set();
|
|
4569
|
+
/**
|
|
4570
|
+
* ADR-4629 §8.2 (C2): did this target measurably change between the two
|
|
4571
|
+
* document sides? Lifted verbatim out of `reconcileReportedFields` (the private
|
|
4572
|
+
* `changed` closure) for the same one-rule reason as `readStateTarget`.
|
|
4573
|
+
*
|
|
4574
|
+
* A target can itself be a declared derived leaf (e.g. `plannedPhaseCore`
|
|
4575
|
+
* pushing `'progress.total_plans'` — state-transition.cts). `readStateTarget`'s
|
|
4576
|
+
* null-vs-string convention cannot tell "absent from the frontmatter" apart
|
|
4577
|
+
* from "resolved to the literal string 'null'/''", so it cannot carry the same
|
|
4578
|
+
* materialization rule `computeChangedFrontmatterFields` applies. Those targets
|
|
4579
|
+
* route through the SAME primitives (`resolveFrontmatterPath` + the
|
|
4580
|
+
* `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4581
|
+
* second, parallel absence convention — one rule, reused, not duplicated.
|
|
4582
|
+
*/
|
|
4583
|
+
function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
|
|
4584
|
+
const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
|
|
4585
|
+
if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
|
|
4586
|
+
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
4587
|
+
const after = resolveFrontmatterPath(persistedFm, field);
|
|
4588
|
+
// Same generalized provenance rule as computeChangedFrontmatterFields:
|
|
4589
|
+
// absent-in-snapshot-materializing-in-persisted is the disk scan
|
|
4590
|
+
// catching a never-synced document up, not this write's own action.
|
|
4591
|
+
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
4592
|
+
return false;
|
|
4593
|
+
return stateFieldValuesDiffer(before, after);
|
|
4594
|
+
}
|
|
4595
|
+
const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
|
|
4596
|
+
const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets);
|
|
4597
|
+
if (before === null && after === null)
|
|
4598
|
+
return false;
|
|
4599
|
+
if (before === null || after === null)
|
|
4600
|
+
return true;
|
|
4601
|
+
return before.trim() !== after.trim();
|
|
4602
|
+
}
|
|
4603
|
+
function reconcileReportedFields(statePath, preWriteState, reported, divergedFields,
|
|
4604
|
+
// ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
|
|
4605
|
+
// `target: 'section'` assertions, measured as whole-section deltas. Omitted
|
|
4606
|
+
// by every pre-C2 caller, whose output is unchanged.
|
|
4607
|
+
sectionTargets = NO_SECTION_TARGETS) {
|
|
4451
4608
|
void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
|
|
4452
4609
|
if (preWriteState.fm === undefined || preWriteState.body === undefined)
|
|
4453
4610
|
return [];
|
|
@@ -4456,71 +4613,7 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
|
|
|
4456
4613
|
const persistedBody = stripFrontmatter(persisted);
|
|
4457
4614
|
const snapshotFm = preWriteState.fm;
|
|
4458
4615
|
const snapshotBody = preWriteState.body;
|
|
4459
|
-
|
|
4460
|
-
// FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
|
|
4461
|
-
// write precedence `patchCore`/`updateCore` apply (#1162's fix — a
|
|
4462
|
-
// lowercase body label that happens to case-exact-match a frontmatter key
|
|
4463
|
-
// must still resolve against the body). `field` a literal flat key (even
|
|
4464
|
-
// one containing a `.`) is tried before it is split and walked as a
|
|
4465
|
-
// dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
|
|
4466
|
-
// same order for the frontmatter side alone.
|
|
4467
|
-
//
|
|
4468
|
-
// `Current Position` is special-cased: it names the WHOLE `## Current
|
|
4469
|
-
// Position` section, not a single `Label: value` line, so
|
|
4470
|
-
// `stateExtractField` can never resolve it (this is the root cause of the
|
|
4471
|
-
// "Current Position undercount" — a transform can correctly push
|
|
4472
|
-
// `'Current Position'` into its own `updated` list, and this function
|
|
4473
|
-
// still silently dropped it, because `valueOf` returned `null` for BOTH
|
|
4474
|
-
// sides and `null === null` failed the old `intended !== null` guard).
|
|
4475
|
-
// `sliceCurrentPositionSection` is the existing fence-aware section
|
|
4476
|
-
// locator (state-transition.cts) — reused rather than re-derived.
|
|
4477
|
-
const valueOf = (fm, body, field) => {
|
|
4478
|
-
if (field === 'Current Position') {
|
|
4479
|
-
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4480
|
-
return section !== null ? section.trim() : null;
|
|
4481
|
-
}
|
|
4482
|
-
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
4483
|
-
if (bodyValue !== null)
|
|
4484
|
-
return bodyValue;
|
|
4485
|
-
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
4486
|
-
return String(fm[field]);
|
|
4487
|
-
if (field.includes('.')) {
|
|
4488
|
-
const resolved = resolveFrontmatterPath(fm, field);
|
|
4489
|
-
if (resolved !== STATE_FIELD_ABSENT) {
|
|
4490
|
-
return stateScalarString(resolved);
|
|
4491
|
-
}
|
|
4492
|
-
}
|
|
4493
|
-
return null;
|
|
4494
|
-
};
|
|
4495
|
-
// A field in `reported` can itself be a declared derived leaf (e.g.
|
|
4496
|
-
// `plannedPhaseCore` pushing `'progress.total_plans'` — state-
|
|
4497
|
-
// transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
|
|
4498
|
-
// "absent from the frontmatter" apart from "resolved to the literal string
|
|
4499
|
-
// 'null'/''", so it cannot carry the same materialization rule
|
|
4500
|
-
// `computeChangedFrontmatterFields` applies below. Route these fields
|
|
4501
|
-
// through the SAME primitives (`resolveFrontmatterPath` + the
|
|
4502
|
-
// `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4503
|
-
// second, parallel absence convention — one rule, reused, not duplicated.
|
|
4504
|
-
const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
|
|
4505
|
-
const changed = (field) => {
|
|
4506
|
-
if (isDeclaredDerivedLeaf(field)) {
|
|
4507
|
-
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
4508
|
-
const after = resolveFrontmatterPath(persistedFm, field);
|
|
4509
|
-
// Same generalized provenance rule as computeChangedFrontmatterFields:
|
|
4510
|
-
// absent-in-snapshot-materializing-in-persisted is the disk scan
|
|
4511
|
-
// catching a never-synced document up, not this write's own action.
|
|
4512
|
-
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
4513
|
-
return false;
|
|
4514
|
-
return stateFieldValuesDiffer(before, after);
|
|
4515
|
-
}
|
|
4516
|
-
const before = valueOf(snapshotFm, snapshotBody, field);
|
|
4517
|
-
const after = valueOf(persistedFm, persistedBody, field);
|
|
4518
|
-
if (before === null && after === null)
|
|
4519
|
-
return false;
|
|
4520
|
-
if (before === null || after === null)
|
|
4521
|
-
return true;
|
|
4522
|
-
return before.trim() !== after.trim();
|
|
4523
|
-
};
|
|
4616
|
+
const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
|
|
4524
4617
|
// Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
|
|
4525
4618
|
// granularity) whose persisted value differs from the snapshot, minus the
|
|
4526
4619
|
// provenance exclusion. A frontmatter-diff-discovered field is mapped
|
|
@@ -4548,6 +4641,361 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
|
|
|
4548
4641
|
}
|
|
4549
4642
|
return reconciled;
|
|
4550
4643
|
}
|
|
4644
|
+
/** Why a verified write failed. A closed set, frozen like `ERROR_REASON`. */
|
|
4645
|
+
const STATE_WRITE_INTENT_FAILURE = Object.freeze({
|
|
4646
|
+
/** §8.3: the delta reached a region outside the declared scope. */
|
|
4647
|
+
OUT_OF_SCOPE: 'out_of_scope',
|
|
4648
|
+
/** §8.2: a `required` assertion did not land. */
|
|
4649
|
+
REQUIRED_ASSERTION_MISSED: 'required_assertion_missed',
|
|
4650
|
+
/** §8.2: the re-read file is not the content that was verified and written. */
|
|
4651
|
+
REREAD_MISMATCH: 'reread_mismatch',
|
|
4652
|
+
});
|
|
4653
|
+
/**
|
|
4654
|
+
* Split a STATE.md body into leaf units for the §8.3 diff: the preamble before
|
|
4655
|
+
* the first heading, then one unit per heading running to the NEXT heading of
|
|
4656
|
+
* any level, in document order. Leaf granularity means a declared
|
|
4657
|
+
* `## Accumulated Context` scope reaches its `### Decisions` child by heading
|
|
4658
|
+
* level (`stateSectionScopeUnits`), while an undeclared sibling section stays a separate unit. Built on
|
|
4659
|
+
* the markdown-sectionizer seam (ADR-1372), which is fence-aware, so a `#` line
|
|
4660
|
+
* inside a code fence is not a heading. A repeated heading gets an occurrence
|
|
4661
|
+
* suffix so neither shadows the other.
|
|
4662
|
+
*/
|
|
4663
|
+
function collectStateBodyUnits(body) {
|
|
4664
|
+
const units = new Map();
|
|
4665
|
+
const sections = (0, markdown_sectionizer_cjs_1.collectSections)(body, () => true);
|
|
4666
|
+
const preambleEnd = sections.length > 0 ? sections[0].heading.offset : body.length;
|
|
4667
|
+
units.set('(preamble)', { text: body.slice(0, preambleEnd).trim(), level: 0, region: { region: 'preamble', name: '' } });
|
|
4668
|
+
const occurrences = new Map();
|
|
4669
|
+
for (const section of sections) {
|
|
4670
|
+
const { heading } = section;
|
|
4671
|
+
const base = `${'#'.repeat(heading.level)} ${heading.text}`;
|
|
4672
|
+
const n = (occurrences.get(base) ?? 0) + 1;
|
|
4673
|
+
occurrences.set(base, n);
|
|
4674
|
+
units.set(n === 1 ? base : `${base} (#${n})`, {
|
|
4675
|
+
text: section.body.trim(),
|
|
4676
|
+
level: heading.level,
|
|
4677
|
+
region: { region: 'section', name: heading.text },
|
|
4678
|
+
});
|
|
4679
|
+
}
|
|
4680
|
+
return units;
|
|
4681
|
+
}
|
|
4682
|
+
/** The raw frontmatter region (everything before the body), for the unparseable-block case. */
|
|
4683
|
+
function stateFrontmatterRegion(content) {
|
|
4684
|
+
return content.slice(0, content.length - stripFrontmatter(content).length);
|
|
4685
|
+
}
|
|
4686
|
+
/**
|
|
4687
|
+
* The unit a declared field lives in: the first unit, in document order, where
|
|
4688
|
+
* `stateExtractField` resolves it. That mirrors `stateExtractField`'s own
|
|
4689
|
+
* first-match reading of the whole body, so a declared `Status` covers the one
|
|
4690
|
+
* line the seam treats as `Status`, and not a second `Status:` line elsewhere.
|
|
4691
|
+
*/
|
|
4692
|
+
function stateFieldOwnerUnit(units, field) {
|
|
4693
|
+
for (const [key, unit] of units) {
|
|
4694
|
+
if ((0, state_document_cjs_1.stateExtractField)(unit.text, field) !== null)
|
|
4695
|
+
return key;
|
|
4696
|
+
}
|
|
4697
|
+
return null;
|
|
4698
|
+
}
|
|
4699
|
+
/**
|
|
4700
|
+
* The units a declared section covers: the FIRST unit, in document order, whose
|
|
4701
|
+
* heading text is the declared name (the occurrence `readStateTarget` reads,
|
|
4702
|
+
* through `collectSection`'s first match), plus every following unit nested
|
|
4703
|
+
* under it by heading level. The section analogue of `stateFieldOwnerUnit`: a
|
|
4704
|
+
* repeated heading of the same name, and that occurrence's children, are not
|
|
4705
|
+
* covered, even though they sit under a heading with the same text.
|
|
4706
|
+
*/
|
|
4707
|
+
function stateSectionScopeUnits(units, sectionTargets) {
|
|
4708
|
+
const covered = new Set();
|
|
4709
|
+
const entries = [...units];
|
|
4710
|
+
for (const name of sectionTargets) {
|
|
4711
|
+
const start = entries.findIndex(([, u]) => u.region.region === 'section' && u.region.name === name);
|
|
4712
|
+
if (start < 0)
|
|
4713
|
+
continue;
|
|
4714
|
+
const ownerLevel = entries[start][1].level;
|
|
4715
|
+
covered.add(entries[start][0]);
|
|
4716
|
+
for (let i = start + 1; i < entries.length && entries[i][1].level > ownerLevel; i++)
|
|
4717
|
+
covered.add(entries[i][0]);
|
|
4718
|
+
}
|
|
4719
|
+
return covered;
|
|
4720
|
+
}
|
|
4721
|
+
/**
|
|
4722
|
+
* ADR-4629 §8.2 + §8.3: verify one write against its declared intent. PURE —
|
|
4723
|
+
* it compares two document sides and never touches disk.
|
|
4724
|
+
*
|
|
4725
|
+
* Both sides are first passed through `normalizeContent`, the OS projection
|
|
4726
|
+
* seam's own normalization (CRLF strip, blank-line-run collapse, one trailing
|
|
4727
|
+
* newline). `platformWriteSync` applies it to every STATE.md write, on this
|
|
4728
|
+
* path and the legacy one alike, so it is the seam's behavior, not the
|
|
4729
|
+
* intent's delta. Without it, a CRLF file would read as every section changed.
|
|
4730
|
+
*
|
|
4731
|
+
* §8.2 (verified post-state): every `required` assertion must land. With a
|
|
4732
|
+
* declared `value`, "landed" means the target reads that value afterwards (an
|
|
4733
|
+
* idempotent write that re-asserts the current value passes). Without one,
|
|
4734
|
+
* "landed" means the target is still present AND measurably changed
|
|
4735
|
+
* (`stateTargetChanged`, the measured-delta test `updated[]` uses); a deleted
|
|
4736
|
+
* target has not landed. Best-effort assertions are NOT judged: their report
|
|
4737
|
+
* bucket is ADR-4629 §8.4, an open question for Phase 3, so this result
|
|
4738
|
+
* deliberately carries no best-effort field for a later phase to be bound by.
|
|
4739
|
+
*
|
|
4740
|
+
* §8.3 (bounded mutation): the delta must stay inside the declared scope.
|
|
4741
|
+
* - Body: diffed per leaf unit (`collectStateBodyUnits`). A changed unit is in
|
|
4742
|
+
* scope when it is the first occurrence of a section a declared
|
|
4743
|
+
* `target: 'section'` assertion names, or nested under that occurrence
|
|
4744
|
+
* (`stateSectionScopeUnits`). Otherwise it is in scope only when replaying the
|
|
4745
|
+
* post-values of the declared fields THAT UNIT OWNS (`stateFieldOwnerUnit`)
|
|
4746
|
+
* onto its pre text reproduces its post text, so any other edit in the same
|
|
4747
|
+
* section, or the same label in another section, is caught. A change in
|
|
4748
|
+
* section ORDER is its own out-of-scope region.
|
|
4749
|
+
* - Frontmatter: keys with a body source (`getFrontmatterBodySource`) are
|
|
4750
|
+
* judged through that source, because `syncStateFrontmatter` re-derives them
|
|
4751
|
+
* from the body on every write. When the seam's `bodyDeltas` show the source
|
|
4752
|
+
* moved, the body check above already bounded that move. A mirrored key that
|
|
4753
|
+
* changed with no body move, or with no `bodyDeltas` to show one, must be
|
|
4754
|
+
* declared. Every other key goes through `computeChangedFrontmatterFields`
|
|
4755
|
+
* (the reporter's rule: `last_updated` provenance exclusion, declared-leaf
|
|
4756
|
+
* materialization). A schema-declared key (a `FIELD_CLASSIFICATION` row)
|
|
4757
|
+
* that is absent before and present after is the seam catching a document
|
|
4758
|
+
* up, not the caller's action, the same provenance rule the reporter
|
|
4759
|
+
* applies. A key already present that the seam re-derives to a new value
|
|
4760
|
+
* (for example `progress.*` under `resync`) IS counted: §8.3 bounds what the
|
|
4761
|
+
* write changed, whoever computed it.
|
|
4762
|
+
* - A frontmatter key is declared when an assertion names it exactly, or when
|
|
4763
|
+
* it is one of the schema-declared leaves (`declaredLeavesOf`) of a key an
|
|
4764
|
+
* assertion names. Declaring `progress` therefore covers the closed set of
|
|
4765
|
+
* `progress.*` rows `FIELD_CLASSIFICATION` lists, the frontmatter analogue of
|
|
4766
|
+
* a `target: 'section'` assertion covering its child sections. Nothing else
|
|
4767
|
+
* widens: a declared leaf (`progress.percent`) never covers a sibling leaf,
|
|
4768
|
+
* and a dotted key outside the schema's leaf set is never covered by its
|
|
4769
|
+
* prefix.
|
|
4770
|
+
* - `scope: 'broad'` puts the WHOLE frontmatter in scope. That is ADR-4629
|
|
4771
|
+
* §8.3's own worked case (`milestoneSwitch` declares a whole-frontmatter
|
|
4772
|
+
* scope). The body stays bounded: broad is audited, not exempted.
|
|
4773
|
+
* - `kind: 'rebuild'` (`cmdStateSync`, `REGENERATE_STATE`) is unbounded by
|
|
4774
|
+
* contract (ADR-3408 §8.3; ADR-4629 §8.3 names those two and no more), so
|
|
4775
|
+
* §8.3 is skipped for it. §8.2 still applies.
|
|
4776
|
+
*
|
|
4777
|
+
* `Current Position` is always read as a whole section, in §8.2 and §8.3 alike,
|
|
4778
|
+
* matching `readStateTarget`'s long-standing special case.
|
|
4779
|
+
*/
|
|
4780
|
+
function verifyStateWriteIntent(intent, preContent, postContent, statePath, bodyDeltas) {
|
|
4781
|
+
const pre = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, preContent).content;
|
|
4782
|
+
const post = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, postContent).content;
|
|
4783
|
+
const preFm = extractFrontmatter(pre, statePath);
|
|
4784
|
+
const postFm = extractFrontmatter(post, statePath);
|
|
4785
|
+
const preBody = stripFrontmatter(pre);
|
|
4786
|
+
const postBody = stripFrontmatter(post);
|
|
4787
|
+
const isSection = (a) => a.target === 'section' || a.field === 'Current Position';
|
|
4788
|
+
const sectionTargets = new Set();
|
|
4789
|
+
const fieldTargets = new Set();
|
|
4790
|
+
for (const a of intent.assertions) {
|
|
4791
|
+
if (isSection(a))
|
|
4792
|
+
sectionTargets.add(a.field);
|
|
4793
|
+
else
|
|
4794
|
+
fieldTargets.add(a.field);
|
|
4795
|
+
}
|
|
4796
|
+
// §8.2 — required assertions.
|
|
4797
|
+
const missedRequired = [];
|
|
4798
|
+
for (const a of intent.assertions) {
|
|
4799
|
+
if (a.requirement !== 'required')
|
|
4800
|
+
continue;
|
|
4801
|
+
const targets = isSection(a) ? new Set([a.field]) : NO_SECTION_TARGETS;
|
|
4802
|
+
const actual = readStateTarget(postFm, postBody, a.field, targets);
|
|
4803
|
+
const landed = a.value !== undefined
|
|
4804
|
+
? actual !== null && actual.trim() === a.value.trim()
|
|
4805
|
+
: actual !== null && stateTargetChanged(preFm, preBody, postFm, postBody, a.field, targets);
|
|
4806
|
+
if (!landed)
|
|
4807
|
+
missedRequired.push(a.field);
|
|
4808
|
+
}
|
|
4809
|
+
// §8.3 — bounded mutation.
|
|
4810
|
+
const outOfScope = [];
|
|
4811
|
+
if (intent.kind !== 'rebuild') {
|
|
4812
|
+
if (intent.scope !== 'broad') {
|
|
4813
|
+
const declared = (key) => {
|
|
4814
|
+
if (fieldTargets.has(key))
|
|
4815
|
+
return true;
|
|
4816
|
+
const dot = key.indexOf('.');
|
|
4817
|
+
if (dot <= 0)
|
|
4818
|
+
return false;
|
|
4819
|
+
const parent = key.slice(0, dot);
|
|
4820
|
+
return fieldTargets.has(parent) && declaredLeavesOf(parent).includes(key);
|
|
4821
|
+
};
|
|
4822
|
+
if (isUnparseableFrontmatter(preFm) || isUnparseableFrontmatter(postFm)) {
|
|
4823
|
+
// An unparseable block has no keys to diff; judge the raw region.
|
|
4824
|
+
if (stateFrontmatterRegion(pre) !== stateFrontmatterRegion(post)) {
|
|
4825
|
+
outOfScope.push({ region: 'frontmatter', name: '(unparseable block)' });
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
else {
|
|
4829
|
+
const schemaMaterialized = (key) => Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, key)
|
|
4830
|
+
&& resolveFrontmatterPath(preFm, key) === STATE_FIELD_ABSENT
|
|
4831
|
+
&& resolveFrontmatterPath(postFm, key) !== STATE_FIELD_ABSENT;
|
|
4832
|
+
// Non-body-sourced keys: `computeChangedFrontmatterFields` skips body-sourced
|
|
4833
|
+
// keys entirely when handed no `bodyDeltas`; those are judged just below.
|
|
4834
|
+
for (const key of computeChangedFrontmatterFields(preFm, postFm, undefined)) {
|
|
4835
|
+
if (!declared(key) && !schemaMaterialized(key))
|
|
4836
|
+
outOfScope.push({ region: 'frontmatter', name: key });
|
|
4837
|
+
}
|
|
4838
|
+
const topKeys = new Set([...Object.keys(preFm), ...Object.keys(postFm)]);
|
|
4839
|
+
for (const key of topKeys) {
|
|
4840
|
+
if (stateTransitionMod.getFrontmatterBodySource(key) === null)
|
|
4841
|
+
continue;
|
|
4842
|
+
const before = resolveFrontmatterPath(preFm, key);
|
|
4843
|
+
const after = resolveFrontmatterPath(postFm, key);
|
|
4844
|
+
if (!stateFieldValuesDiffer(before, after))
|
|
4845
|
+
continue;
|
|
4846
|
+
if (before === STATE_FIELD_ABSENT)
|
|
4847
|
+
continue; // back-fill (the #1264 case), not the caller's action
|
|
4848
|
+
const delta = bodyDeltas ? bodyDeltas[key] : undefined;
|
|
4849
|
+
const bodyMoved = delta !== undefined
|
|
4850
|
+
&& stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT);
|
|
4851
|
+
if (bodyMoved || declared(key))
|
|
4852
|
+
continue;
|
|
4853
|
+
outOfScope.push({ region: 'frontmatter', name: key });
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
const preUnits = collectStateBodyUnits(preBody);
|
|
4858
|
+
const postUnits = collectStateBodyUnits(postBody);
|
|
4859
|
+
const owners = new Map();
|
|
4860
|
+
for (const field of fieldTargets) {
|
|
4861
|
+
const owner = stateFieldOwnerUnit(postUnits, field) ?? stateFieldOwnerUnit(preUnits, field);
|
|
4862
|
+
if (owner !== null)
|
|
4863
|
+
owners.set(owner, [...(owners.get(owner) ?? []), field]);
|
|
4864
|
+
}
|
|
4865
|
+
const sectionScope = new Set([
|
|
4866
|
+
...stateSectionScopeUnits(preUnits, sectionTargets),
|
|
4867
|
+
...stateSectionScopeUnits(postUnits, sectionTargets),
|
|
4868
|
+
]);
|
|
4869
|
+
for (const key of new Set([...preUnits.keys(), ...postUnits.keys()])) {
|
|
4870
|
+
const before = preUnits.get(key);
|
|
4871
|
+
const after = postUnits.get(key);
|
|
4872
|
+
if (before !== undefined && after !== undefined && before.text === after.text)
|
|
4873
|
+
continue;
|
|
4874
|
+
const unit = (after ?? before);
|
|
4875
|
+
if (sectionScope.has(key))
|
|
4876
|
+
continue;
|
|
4877
|
+
if (before !== undefined && after !== undefined) {
|
|
4878
|
+
let replayed = before.text;
|
|
4879
|
+
for (const field of owners.get(key) ?? []) {
|
|
4880
|
+
const next = (0, state_document_cjs_1.stateExtractField)(after.text, field);
|
|
4881
|
+
if (next === null)
|
|
4882
|
+
continue;
|
|
4883
|
+
const applied = (0, state_document_cjs_1.stateReplaceField)(replayed, field, next);
|
|
4884
|
+
if (applied !== null)
|
|
4885
|
+
replayed = applied;
|
|
4886
|
+
}
|
|
4887
|
+
if (replayed.trim() === after.text)
|
|
4888
|
+
continue;
|
|
4889
|
+
}
|
|
4890
|
+
outOfScope.push(unit.region);
|
|
4891
|
+
}
|
|
4892
|
+
const preOrder = [...preUnits.keys()].filter((k) => postUnits.has(k));
|
|
4893
|
+
const postOrder = [...postUnits.keys()].filter((k) => preUnits.has(k));
|
|
4894
|
+
if (preOrder.join('\n') !== postOrder.join('\n'))
|
|
4895
|
+
outOfScope.push({ region: 'order', name: '' });
|
|
4896
|
+
}
|
|
4897
|
+
const reasons = [];
|
|
4898
|
+
if (outOfScope.length > 0)
|
|
4899
|
+
reasons.push(STATE_WRITE_INTENT_FAILURE.OUT_OF_SCOPE);
|
|
4900
|
+
if (missedRequired.length > 0)
|
|
4901
|
+
reasons.push(STATE_WRITE_INTENT_FAILURE.REQUIRED_ASSERTION_MISSED);
|
|
4902
|
+
return Object.freeze({
|
|
4903
|
+
ok: reasons.length === 0,
|
|
4904
|
+
reasons: Object.freeze(reasons),
|
|
4905
|
+
outOfScope: Object.freeze(outOfScope.map((r) => Object.freeze({ ...r }))),
|
|
4906
|
+
missedRequired: Object.freeze(missedRequired),
|
|
4907
|
+
});
|
|
4908
|
+
}
|
|
4909
|
+
const NO_UPDATED_FIELDS = Object.freeze([]);
|
|
4910
|
+
/**
|
|
4911
|
+
* ADR-4629 §8.2 + §8.3: apply a declared `StateWriteIntent` through the one
|
|
4912
|
+
* write seam and fail loud instead of reporting success.
|
|
4913
|
+
*
|
|
4914
|
+
* Under ONE lock (the same `acquireStateLock` `readModifyWriteStateMd` holds):
|
|
4915
|
+
* read -> transform -> `syncAndPreserveStateMd` (the owned composition,
|
|
4916
|
+
* driven by the intent's own `resync` / `deriveProgressKeys` /
|
|
4917
|
+
* `explicitProgressField`) -> verify the would-be content -> write only if
|
|
4918
|
+
* it verified -> re-read -> confirm the re-read file is exactly the verified
|
|
4919
|
+
* content (§8.2 verifies against the re-read file) -> report `updated[]`
|
|
4920
|
+
* from the measured disk delta.
|
|
4921
|
+
* A §8.2/§8.3 violation is refused BEFORE the write, so the file is left
|
|
4922
|
+
* untouched. The re-read check is the one failure found AFTER the write: if
|
|
4923
|
+
* the re-read does not match what was verified, the result is
|
|
4924
|
+
* `reasons: ['reread_mismatch']` with `written: true`. The file is NOT restored
|
|
4925
|
+
* on that arm: the write reached disk but is not verified, and the result says
|
|
4926
|
+
* so rather than attempting a restore through a write path that would itself
|
|
4927
|
+
* normalize the bytes. Its `outOfScope` / `missedRequired` are empty, because
|
|
4928
|
+
* the candidate content itself verified; what failed is the disk round-trip.
|
|
4929
|
+
*
|
|
4930
|
+
* "Loud" means the result, not an exception: a verification failure is a
|
|
4931
|
+
* frozen `ok: false` value naming each reason, never a success payload.
|
|
4932
|
+
* Turning that into a non-zero exit is the CLI caller's job, which lands with
|
|
4933
|
+
* the first migrated caller (C3+). Construction errors (no intent, a rebuild
|
|
4934
|
+
* intent) throw, like `createStateTransaction`'s.
|
|
4935
|
+
*
|
|
4936
|
+
* A transform whose output is byte-identical to its input is the #948 no-op:
|
|
4937
|
+
* nothing is written, but the intent is still verified, so a `required`
|
|
4938
|
+
* assertion that expected a change reports as missed rather than passing.
|
|
4939
|
+
*
|
|
4940
|
+
* Scope fence (C2): open-kind intents only. The two `rebuild` writers stay on
|
|
4941
|
+
* `writeStateMd`, the sanctioned exception path (ADR-3408 §8.3). The intent's
|
|
4942
|
+
* `snapshot` is not compared against the file read under the lock;
|
|
4943
|
+
* optimistic concurrency on the prior value is outside ADR-4629 §8 as written.
|
|
4944
|
+
*/
|
|
4945
|
+
function applyStateWriteIntent(statePath, intent, transformFn, cwd, options = {}, clock) {
|
|
4946
|
+
// Checked on an `unknown` view: `Array.isArray` on the typed ReadonlyArray would narrow it to `any[]`.
|
|
4947
|
+
if (intent === null || typeof intent !== 'object' || !Array.isArray(intent.assertions)) {
|
|
4948
|
+
const err = new Error('applyStateWriteIntent: a StateWriteIntent is required (build it with createStateWriteIntent). ' +
|
|
4949
|
+
'Per ADR-4629 §8.1, an absent intent is a construction failure, not an unverified write.');
|
|
4950
|
+
err.code = 'STATE_WRITE_INTENT_REQUIRED';
|
|
4951
|
+
throw err;
|
|
4952
|
+
}
|
|
4953
|
+
if (intent.kind === 'rebuild') {
|
|
4954
|
+
const err = new Error('applyStateWriteIntent: a rebuild intent cannot travel the preservation seam. The two rebuild ' +
|
|
4955
|
+
'writers (cmdStateSync, REGENERATE_STATE) stay on writeStateMd per ADR-3408 §8.3; ADR-4629 §8.3 ' +
|
|
4956
|
+
'treats them as unbounded by contract.');
|
|
4957
|
+
err.code = 'STATE_TRANSACTION_KIND_INVALID';
|
|
4958
|
+
throw err;
|
|
4959
|
+
}
|
|
4960
|
+
const lockPath = acquireStateLock(statePath, clock);
|
|
4961
|
+
try {
|
|
4962
|
+
const pre = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
4963
|
+
const transformed = transformFn(pre);
|
|
4964
|
+
const unwritten = (verification) => Object.freeze({ ...verification, written: false, updated: NO_UPDATED_FIELDS });
|
|
4965
|
+
if (transformed === pre) {
|
|
4966
|
+
return unwritten(verifyStateWriteIntent(intent, pre, pre, statePath));
|
|
4967
|
+
}
|
|
4968
|
+
const preWriteState = {};
|
|
4969
|
+
const candidate = syncAndPreserveStateMd(pre, transformed, statePath, cwd, {
|
|
4970
|
+
resync: intent.resync,
|
|
4971
|
+
deriveProgressKeys: intent.deriveProgressKeys,
|
|
4972
|
+
explicitProgressField: intent.explicitProgressField,
|
|
4973
|
+
authoritativeFm: options.authoritativeFm,
|
|
4974
|
+
preWriteState,
|
|
4975
|
+
});
|
|
4976
|
+
const verification = verifyStateWriteIntent(intent, pre, candidate, statePath, preWriteState.bodyDeltas);
|
|
4977
|
+
if (!verification.ok)
|
|
4978
|
+
return unwritten(verification);
|
|
4979
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, candidate);
|
|
4980
|
+
const reread = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
4981
|
+
if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate).content) {
|
|
4982
|
+
return Object.freeze({
|
|
4983
|
+
ok: false,
|
|
4984
|
+
reasons: Object.freeze([STATE_WRITE_INTENT_FAILURE.REREAD_MISMATCH]),
|
|
4985
|
+
outOfScope: verification.outOfScope,
|
|
4986
|
+
missedRequired: verification.missedRequired,
|
|
4987
|
+
written: true,
|
|
4988
|
+
updated: NO_UPDATED_FIELDS,
|
|
4989
|
+
});
|
|
4990
|
+
}
|
|
4991
|
+
const sectionTargets = new Set(intent.assertions.filter((a) => a.target === 'section' || a.field === 'Current Position').map((a) => a.field));
|
|
4992
|
+
const updated = reconcileReportedFields(statePath, preWriteState, intent.assertions.map((a) => a.field), [], sectionTargets);
|
|
4993
|
+
return Object.freeze({ ...verification, written: true, updated: Object.freeze(updated) });
|
|
4994
|
+
}
|
|
4995
|
+
finally {
|
|
4996
|
+
releaseStateLock(lockPath);
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4551
4999
|
function cmdStateJson(cwd, raw) {
|
|
4552
5000
|
const statePath = planningPaths(cwd).state;
|
|
4553
5001
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -4567,7 +5015,7 @@ function cmdStateJson(cwd, raw) {
|
|
|
4567
5015
|
// reports the phase-directory count while the persisted file preserves the
|
|
4568
5016
|
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
|
4569
5017
|
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
4570
|
-
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
5018
|
+
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm), isMilestoneExplicitlyNull(existingFm));
|
|
4571
5019
|
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
|
4572
5020
|
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
|
4573
5021
|
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
|
@@ -5563,7 +6011,13 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5563
6011
|
// #612: `syncConvention` threaded — the write-side half of
|
|
5564
6012
|
// buildStateFrontmatter's thread above, so `state sync` and `state json`
|
|
5565
6013
|
// keep agreeing on completed_phases under the bracket convention.
|
|
5566
|
-
|
|
6014
|
+
// #5118 (no write before the error): `state sync` writes STATE.md only
|
|
6015
|
+
// after this scan, so an out-of-set report status fails it here, having
|
|
6016
|
+
// written nothing.
|
|
6017
|
+
const completion = isPhaseComplete(dirPath, { convention: syncConvention });
|
|
6018
|
+
if (completion.value.statusError)
|
|
6019
|
+
throw completion.value.statusError;
|
|
6020
|
+
if (completion.value.complete)
|
|
5567
6021
|
diskCompletedPhases++;
|
|
5568
6022
|
// Track the highest phase with incomplete plans (or any plans)
|
|
5569
6023
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
@@ -5610,8 +6064,10 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5610
6064
|
// set — leave Progress untouched (percent=null) rather than silently writing
|
|
5611
6065
|
// fallback-derived wrong values. Projects without a milestone version (the common
|
|
5612
6066
|
// sync-test shape) are unaffected: the gate only fires when a version is asserted.
|
|
5613
|
-
const
|
|
5614
|
-
const
|
|
6067
|
+
const syncFm = extractFrontmatter(content, statePath);
|
|
6068
|
+
const fmVersion = syncFm.milestone;
|
|
6069
|
+
const milestoneExplicitlyNull = isMilestoneExplicitlyNull(syncFm);
|
|
6070
|
+
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() && !milestoneExplicitlyNull ? fmVersion.trim() : null;
|
|
5615
6071
|
let milestoneBounded = true;
|
|
5616
6072
|
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
5617
6073
|
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
|
|
@@ -5620,6 +6076,10 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
5620
6076
|
// extension on top inside isMilestoneBounded.
|
|
5621
6077
|
milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention);
|
|
5622
6078
|
}
|
|
6079
|
+
else if (milestoneExplicitlyNull && syncRoadmapRaw !== null && hasAnyMilestoneSection(syncRoadmapRaw)) {
|
|
6080
|
+
// #5038: mirror buildStateFrontmatter's silent explicit-null withhold.
|
|
6081
|
+
milestoneBounded = false;
|
|
6082
|
+
}
|
|
5623
6083
|
let percent = null;
|
|
5624
6084
|
if (!milestoneBounded) {
|
|
5625
6085
|
changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
|
|
@@ -6169,6 +6629,35 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
6169
6629
|
if (wrote)
|
|
6170
6630
|
publishStateContract(cwd);
|
|
6171
6631
|
}
|
|
6632
|
+
/**
|
|
6633
|
+
* #5118 (no write before the error): throws `VerificationStatusError` when a
|
|
6634
|
+
* verification report in the set every STATE.md write rebuilds its frontmatter
|
|
6635
|
+
* from carries a `status` outside the closed set — and returns having done
|
|
6636
|
+
* nothing else. It runs `buildStateFrontmatter` itself (the SAME disk scan,
|
|
6637
|
+
* milestone scoping and dedupe every write performs), so a caller that writes
|
|
6638
|
+
* OTHER files before STATE.md (`phase complete`, `phase remove`, `quick
|
|
6639
|
+
* archive`, `validate health --repair`) refuses for exactly the phases the
|
|
6640
|
+
* STATE.md write would refuse for — the set cannot drift from a second copy.
|
|
6641
|
+
*/
|
|
6642
|
+
function assertVerificationReportsReadable(bodyContent, cwd) {
|
|
6643
|
+
buildStateFrontmatter(bodyContent, cwd);
|
|
6644
|
+
}
|
|
6645
|
+
/**
|
|
6646
|
+
* The phase directory NAMES (under `.planning/phases/`) a STATE.md frontmatter
|
|
6647
|
+
* rebuild scans — milestone-scoped, retired phases dropped, one per phase key
|
|
6648
|
+
* (`scanStatePhaseDirs`). For a caller that must validate the reports a rebuild
|
|
6649
|
+
* WILL read after it has removed a directory of its own (`phase remove`): it
|
|
6650
|
+
* takes this set and drops the directory it is about to delete (#5118).
|
|
6651
|
+
*/
|
|
6652
|
+
function statePhaseDirsToScan(cwd) {
|
|
6653
|
+
const phasesDir = planningPaths(cwd).phases;
|
|
6654
|
+
if (!node_fs_1.default.existsSync(phasesDir))
|
|
6655
|
+
return [];
|
|
6656
|
+
// quiet: this is a pre-write validation pass, always followed by the real
|
|
6657
|
+
// rebuild scan (buildStateFrontmatter), which owns the duplicate-phase-key
|
|
6658
|
+
// stderr warning — so the command prints it once, not twice.
|
|
6659
|
+
return scanStatePhaseDirs(cwd, phasesDir, resolvePhaseIdConvention(cwd), undefined, { quiet: true }).phaseDirs;
|
|
6660
|
+
}
|
|
6172
6661
|
module.exports = {
|
|
6173
6662
|
stateExtractField: state_document_cjs_1.stateExtractField,
|
|
6174
6663
|
stateReplaceField: state_document_cjs_1.stateReplaceField,
|
|
@@ -6178,6 +6667,8 @@ module.exports = {
|
|
|
6178
6667
|
writeStateMd,
|
|
6179
6668
|
readModifyWriteStateMd,
|
|
6180
6669
|
syncStateFrontmatter,
|
|
6670
|
+
assertVerificationReportsReadable,
|
|
6671
|
+
statePhaseDirsToScan,
|
|
6181
6672
|
// #3374: the shared post-sync preservation pass (snapshots + table-driven
|
|
6182
6673
|
// applyStatePreservation + #2736 re-assert).
|
|
6183
6674
|
applyPostSyncPreservation,
|
|
@@ -6239,6 +6730,12 @@ module.exports = {
|
|
|
6239
6730
|
// pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
|
|
6240
6731
|
// itself.
|
|
6241
6732
|
_bodyLabelFor: bodyLabelFor,
|
|
6733
|
+
// ADR-4629 §8.2/§8.3 (epic #4629, C2 #4866): the verifying executor and its
|
|
6734
|
+
// pure verifier. No production caller constructs a StateWriteIntent yet (C3+),
|
|
6735
|
+
// so these are reached only by their positive-control tests.
|
|
6736
|
+
applyStateWriteIntent,
|
|
6737
|
+
_verifyStateWriteIntent: verifyStateWriteIntent,
|
|
6738
|
+
STATE_WRITE_INTENT_FAILURE,
|
|
6242
6739
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
6243
6740
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
6244
6741
|
_setLockProbes(probes) {
|