@opengsd/gsd-core 1.9.1 → 1.11.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 +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +27 -3
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +12 -10
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +46 -0
- package/agents/gsd-planner.md +49 -54
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +26 -73
- package/bin/install.js +1272 -1238
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +2 -2
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/plan-phase.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1009 -115
- package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
- package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
- package/gsd-core/bin/lib/api-coverage.cjs +123 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +926 -202
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +608 -148
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +507 -24
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +114 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +94 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +665 -99
- package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +76 -0
- package/gsd-core/bin/lib/config.cjs +22 -2
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +217 -40
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +239 -32
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1325 -169
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +805 -264
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +160 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
- package/gsd-core/bin/lib/io.cjs +38 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +821 -109
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +507 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +258 -58
- package/gsd-core/bin/lib/phase.cjs +891 -156
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +405 -84
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
- package/gsd-core/bin/lib/smart-entry.cjs +154 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +424 -105
- package/gsd-core/bin/lib/state.cjs +1927 -401
- package/gsd-core/bin/lib/surface.cjs +35 -10
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
- package/gsd-core/bin/lib/uat.cjs +706 -64
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +33 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -20
- package/gsd-core/bin/lib/verify.cjs +368 -880
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
- package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +8 -7
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +33 -70
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +74 -166
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +160 -95
- package/gsd-core/workflows/debug.md +16 -17
- package/gsd-core/workflows/diagnose-issues.md +56 -8
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +103 -187
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +7 -6
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +35 -47
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +27 -240
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +89 -209
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +45 -159
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +55 -405
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +41 -13
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +41 -20
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +106 -13
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +53 -31
- package/gsd-core/workflows/ui-phase.md +13 -12
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +19 -8
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +17 -65
- package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
- package/hooks/dist/gsd-check-update-worker.js +64 -12
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +45 -24
- package/hooks/dist/gsd-statusline.js +90 -6
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +277 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +517 -0
- package/hooks/gsd-check-update-worker.js +64 -12
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +607 -26
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +45 -24
- package/hooks/gsd-statusline.js +90 -6
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +277 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +31 -10
- package/pi/gsd.cjs +71 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +68 -6
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +439 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +150 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +40 -3
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +52 -6
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +2 -2
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +2 -2
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +1 -2
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -577
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/gen-emitted-baseline.cjs +0 -145
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1050
|
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
11
11
|
};
|
|
12
12
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
14
15
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
16
|
const ioMod = require("./io.cjs");
|
|
16
17
|
const { output, error } = ioMod;
|
|
@@ -19,10 +20,10 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
19
20
|
const { loadConfig } = configLoaderMod;
|
|
20
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
22
|
const phaseIdMod = require("./phase-id.cjs");
|
|
22
|
-
const {
|
|
23
|
+
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
|
|
23
24
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
25
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
25
|
-
const { getMilestoneInfo,
|
|
26
|
+
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
|
|
26
27
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
27
28
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
29
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
@@ -30,16 +31,36 @@ const { planningDir, planningPaths } = planningWorkspace;
|
|
|
30
31
|
const clock_cjs_1 = require("./clock.cjs");
|
|
31
32
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
33
|
const frontmatter = require("./frontmatter.cjs");
|
|
33
|
-
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
|
|
34
|
+
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter;
|
|
34
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
36
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
36
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const verificationMod = require("./verification.cjs");
|
|
39
|
+
const { isPhaseComplete } = verificationMod;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
42
|
+
const { SCOPE } = planningScopeMod;
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
44
|
+
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
45
|
+
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
47
|
const stateTransitionMod = require("./state-transition.cjs");
|
|
48
|
+
// #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
|
|
49
|
+
// node builtins, so it introduces no cycle on this path.
|
|
50
|
+
const project_root_cjs_1 = require("./project-root.cjs");
|
|
51
|
+
// #3311: advisory (phase, session) claim over the single Current Position slot.
|
|
52
|
+
// Imports only node builtins + planning-workspace + active-workstream-store, so
|
|
53
|
+
// it introduces no cycle on this path.
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
55
|
+
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
38
56
|
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
|
39
57
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
40
58
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
41
59
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
42
60
|
const validate_cjs_1 = require("./validate.cjs");
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
|
+
const healthDiagnosticTypesMod = require("./health-diagnostic-types.cjs");
|
|
63
|
+
const { SEVERITY, adviseRemedy } = healthDiagnosticTypesMod;
|
|
43
64
|
const STATE_PROGRESS_RESYNC_FIELDS = new Set([
|
|
44
65
|
'Progress',
|
|
45
66
|
'Total Plans in Phase',
|
|
@@ -123,25 +144,38 @@ function _stateHolderVerifiedLive(lockPath) {
|
|
|
123
144
|
return pid !== null && _stateLockIsPidAlive(pid);
|
|
124
145
|
}
|
|
125
146
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* promptly) from an EMPTY/unparseable one (the create→write window — do not steal while
|
|
129
|
-
* fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision
|
|
130
|
-
* in acquireStateLock reads the pid directly (PR #1532 review, window a).
|
|
147
|
+
* Read + classify the lock body at `lockPath`. See `LockBodyStatus` for the
|
|
148
|
+
* three-way distinction the steal decision in `acquireStateLock` relies on.
|
|
131
149
|
*/
|
|
132
|
-
function
|
|
150
|
+
function _stateLockBodyStatus(lockPath) {
|
|
133
151
|
let body;
|
|
134
152
|
try {
|
|
135
153
|
body = node_fs_1.default.readFileSync(lockPath, 'utf-8');
|
|
136
154
|
}
|
|
137
155
|
catch {
|
|
138
|
-
return
|
|
156
|
+
return { kind: 'unreadable' };
|
|
139
157
|
}
|
|
140
158
|
const trimmed = body.trim();
|
|
141
159
|
const pid = parseInt(trimmed, 10);
|
|
142
160
|
if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== trimmed)
|
|
143
|
-
return
|
|
144
|
-
return pid;
|
|
161
|
+
return { kind: 'empty' };
|
|
162
|
+
return { kind: 'pid', pid };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Parse the lock body to its recorded pid, or null when the body is empty / non-numeric
|
|
166
|
+
* / unreadable (legacy or mid-creation). Distinguishing a COMPLETE dead-pid body (steal
|
|
167
|
+
* promptly) from an EMPTY/unparseable one (the create→write window — do not steal while
|
|
168
|
+
* fresh) is what `_stateHolderVerifiedLive` alone cannot express, so the steal decision
|
|
169
|
+
* in acquireStateLock reads the pid directly (PR #1532 review, window a).
|
|
170
|
+
*
|
|
171
|
+
* NOTE: this collapses "genuinely empty" and "unreadable" to the same `null` —
|
|
172
|
+
* that is fine for `_stateHolderVerifiedLive` (both mean "not verified-live"
|
|
173
|
+
* either way), but the STEAL-TIMING decision must not make that same
|
|
174
|
+
* collapse (#3057 B2) and reads `_stateLockBodyStatus` directly instead.
|
|
175
|
+
*/
|
|
176
|
+
function _stateLockBodyPid(lockPath) {
|
|
177
|
+
const status = _stateLockBodyStatus(lockPath);
|
|
178
|
+
return status.kind === 'pid' ? status.pid : null;
|
|
145
179
|
}
|
|
146
180
|
// Monotonic sequence for unique stale-steal rename targets (no crypto dependency).
|
|
147
181
|
let _stateStealSeq = 0;
|
|
@@ -160,7 +194,8 @@ const STOP_H2_H3 = (lv) => lv === 2 || lv === 3;
|
|
|
160
194
|
const STOP_H2_ONLY = (lv) => lv === 2;
|
|
161
195
|
function cmdStateLoad(cwd, raw) {
|
|
162
196
|
const config = loadConfig(cwd);
|
|
163
|
-
const
|
|
197
|
+
const paths = planningPaths(cwd);
|
|
198
|
+
const planDir = paths.planning;
|
|
164
199
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planDir, 'STATE.md')) || '';
|
|
165
200
|
const configExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'config.json'));
|
|
166
201
|
const roadmapExists = node_fs_1.default.existsSync(node_path_1.default.join(planDir, 'ROADMAP.md'));
|
|
@@ -173,10 +208,12 @@ function cmdStateLoad(cwd, raw) {
|
|
|
173
208
|
config_exists: configExists,
|
|
174
209
|
// #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
|
|
175
210
|
// spawned subagent's own cwd may differ from the orchestrator's.
|
|
176
|
-
// debug.md has
|
|
177
|
-
// `state load
|
|
178
|
-
//
|
|
179
|
-
|
|
211
|
+
// #3149: debug.md now has its own `init.debug` entry point and reads this
|
|
212
|
+
// field from there, not from `state load`. This stays on the state.load
|
|
213
|
+
// bundle regardless: it is a shipped query surface with its own test anchor
|
|
214
|
+
// (tests/state.test.cjs), so narrowing it would break unseen consumers for
|
|
215
|
+
// no gain (Hyrum's Law). Both emit the SAME `planningPaths(cwd).debug`.
|
|
216
|
+
debug_dir: (0, shell_command_projection_cjs_1.toPosixPath)(paths.debug),
|
|
180
217
|
};
|
|
181
218
|
// For --raw, output a condensed key=value format
|
|
182
219
|
if (raw) {
|
|
@@ -213,7 +250,7 @@ function cmdStateGet(cwd, section, raw) {
|
|
|
213
250
|
return;
|
|
214
251
|
}
|
|
215
252
|
// Try to find markdown section or field
|
|
216
|
-
const fieldEscaped = escapeRegex(section);
|
|
253
|
+
const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
|
|
217
254
|
// Check for **field:** value (bold format)
|
|
218
255
|
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
|
|
219
256
|
const boldMatch = content.match(boldPattern);
|
|
@@ -274,12 +311,33 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
274
311
|
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
|
|
275
312
|
// delta (table-driven) that this phase adds. Field-name validation (security)
|
|
276
313
|
// and the resync-progress decision stay in this adapter.
|
|
277
|
-
let
|
|
314
|
+
let precomputed = { updated: [], failed: [] };
|
|
315
|
+
let preSyncContent = '';
|
|
316
|
+
const divergedFields = [];
|
|
278
317
|
readModifyWriteStateMd(statePath, (content) => {
|
|
279
|
-
const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock
|
|
280
|
-
|
|
318
|
+
const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
|
|
319
|
+
precomputed = result.data ?? precomputed;
|
|
320
|
+
preSyncContent = result.content;
|
|
281
321
|
return result.content;
|
|
282
|
-
}, cwd, { resync: shouldResync });
|
|
322
|
+
}, cwd, { resync: shouldResync, divergedFields });
|
|
323
|
+
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
|
|
324
|
+
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
|
325
|
+
// MATCHED — but its plain-line pattern (`m` flag over the full document)
|
|
326
|
+
// can match the YAML frontmatter line for a lower-cased key, and the write
|
|
327
|
+
// pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
|
|
328
|
+
// preservation rows) then discards or restores that text before the file is
|
|
329
|
+
// saved. A field is only reported `updated` when its post-write on-disk
|
|
330
|
+
// value equals what THIS transform actually wrote (the frontmatter key
|
|
331
|
+
// when present, else the body field — the legitimate working case for
|
|
332
|
+
// state.patch is display-cased BODY fields — Status, Current Plan, Phase —
|
|
333
|
+
// which are never frontmatter keys). Also folds in any field
|
|
334
|
+
// `applyStatePreservation` restored that this patch never named at all
|
|
335
|
+
// (#3345's direction), a case the pre-#3471 version of this command never
|
|
336
|
+
// covered.
|
|
337
|
+
const updated = reconcileReportedFields(statePath, preSyncContent, precomputed.updated, divergedFields);
|
|
338
|
+
const updatedSet = new Set(updated);
|
|
339
|
+
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
|
|
340
|
+
const results = { updated, failed };
|
|
283
341
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
284
342
|
}
|
|
285
343
|
catch {
|
|
@@ -300,6 +358,8 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
300
358
|
const statePath = planningPaths(cwd).state;
|
|
301
359
|
try {
|
|
302
360
|
let updated = false;
|
|
361
|
+
let preSyncContent = '';
|
|
362
|
+
const divergedFields = [];
|
|
303
363
|
const shouldResync = shouldResyncStateProgress([field]);
|
|
304
364
|
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
|
305
365
|
// body-strip/reassemble single-field update is the pure `updateCore` in
|
|
@@ -308,15 +368,26 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
308
368
|
// Preserve curated progress for body-only updates, but allow fields that
|
|
309
369
|
// directly project into progress.* frontmatter to rebuild after mutation.
|
|
310
370
|
readModifyWriteStateMd(statePath, (content) => {
|
|
311
|
-
const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock
|
|
371
|
+
const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
|
|
312
372
|
updated = result.data?.updated === true;
|
|
373
|
+
preSyncContent = result.content;
|
|
313
374
|
return result.content;
|
|
314
|
-
}, cwd, { resync: shouldResync });
|
|
375
|
+
}, cwd, { resync: shouldResync, divergedFields });
|
|
376
|
+
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
|
377
|
+
// `updateCore`'s own match does not know whether sync/preservation later
|
|
378
|
+
// discarded the value it wrote (#3351's direction, generalized from
|
|
379
|
+
// `cmdStatePatch`). `preserved` folds in any OTHER field preservation
|
|
380
|
+
// restored during this write that this command never touched at all
|
|
381
|
+
// (#3345's direction) — reported separately from `updated` because this
|
|
382
|
+
// command's contract is a single-field boolean, not a per-field array.
|
|
383
|
+
const reconciled = reconcileReportedFields(statePath, preSyncContent, updated ? [field] : [], divergedFields);
|
|
384
|
+
updated = reconciled.includes(field);
|
|
385
|
+
const preserved = reconciled.filter((f) => f !== field);
|
|
315
386
|
if (updated) {
|
|
316
|
-
output({ updated: true }, false, undefined);
|
|
387
|
+
output({ updated: true, preserved }, false, undefined);
|
|
317
388
|
}
|
|
318
389
|
else {
|
|
319
|
-
output({ updated: false, reason: `Field "${field}" not found in STATE.md
|
|
390
|
+
output({ updated: false, reason: `Field "${field}" not found in STATE.md`, preserved }, false, undefined);
|
|
320
391
|
}
|
|
321
392
|
}
|
|
322
393
|
catch {
|
|
@@ -359,24 +430,53 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
359
430
|
const intent = { kind: 'advancePlan' };
|
|
360
431
|
const deps = {
|
|
361
432
|
clock: clock_cjs_1.realClock,
|
|
362
|
-
progressProvider: () => null,
|
|
363
433
|
sourcePath: statePath,
|
|
364
434
|
};
|
|
365
435
|
let resultData;
|
|
436
|
+
let precomputedUpdated = [];
|
|
437
|
+
let preSyncContent = '';
|
|
438
|
+
const divergedFields = [];
|
|
439
|
+
// #3311: the milestone (phase + session) claim is consulted INSIDE the
|
|
440
|
+
// STATE.md lock, so the position read and the claim read cannot interleave
|
|
441
|
+
// with another session's Current Position write.
|
|
442
|
+
let milestoneConflict = null;
|
|
366
443
|
readModifyWriteStateMd(statePath, (content) => {
|
|
444
|
+
// advance-plan has no phase argument of its own — the phase it advances is
|
|
445
|
+
// whatever ## Current Position names. Compare that against the milestone
|
|
446
|
+
// claim: a mismatch means another session moved the single-slot position
|
|
447
|
+
// away from the claimed phase (the #3311 flip) and must be surfaced, not
|
|
448
|
+
// silently absorbed.
|
|
449
|
+
const body = stripFrontmatter(content);
|
|
450
|
+
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
451
|
+
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
452
|
+
if (positionPhase !== null) {
|
|
453
|
+
milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
|
|
454
|
+
if (milestoneConflict) {
|
|
455
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, 'state.advance-plan');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
367
458
|
const result = transitionCore(content, intent, deps);
|
|
368
459
|
resultData = result.data;
|
|
460
|
+
precomputedUpdated = result.updated;
|
|
461
|
+
preSyncContent = result.content;
|
|
369
462
|
return result.content;
|
|
370
|
-
}, cwd);
|
|
463
|
+
}, cwd, { divergedFields });
|
|
371
464
|
if (!resultData || resultData['error']) {
|
|
372
465
|
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
|
373
466
|
return;
|
|
374
467
|
}
|
|
468
|
+
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
|
|
469
|
+
// the bytes actually persisted — this command previously reported none of
|
|
470
|
+
// its per-field writes at all (`updated` never left `advancePlanCore`).
|
|
471
|
+
// Generalizes fix(#3351) (closes #3351's direction) and folds in any field
|
|
472
|
+
// preservation restored that this transform never touched (#3345's
|
|
473
|
+
// direction).
|
|
474
|
+
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
|
|
375
475
|
if (resultData['advanced'] === false) {
|
|
376
|
-
output(resultData, raw, 'false');
|
|
476
|
+
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
|
|
377
477
|
}
|
|
378
478
|
else {
|
|
379
|
-
output(resultData, raw, 'true');
|
|
479
|
+
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
|
|
380
480
|
}
|
|
381
481
|
}
|
|
382
482
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
@@ -531,35 +631,126 @@ function cmdStateRecordMetric(cwd, options, raw) {
|
|
|
531
631
|
result['created'] = true;
|
|
532
632
|
output(result, raw, 'true');
|
|
533
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* #3583: computes the write-path percent AND the completed/total plan counts
|
|
636
|
+
* reported alongside it from ONE `buildStateFrontmatter` call, so
|
|
637
|
+
* `cmdStateUpdateProgress`'s JSON output cannot report a `percent` that
|
|
638
|
+
* disagrees with its own `completed`/`total` (`buildStateFrontmatter`'s
|
|
639
|
+
* `progress.{percent,completed_plans,total_plans}` all come from the same
|
|
640
|
+
* disk scan, scoped to the STORED `milestone:` frontmatter value — #3017).
|
|
641
|
+
* Re-deriving completed/total from a second, differently-scoped scan (the
|
|
642
|
+
* auto-derived one `cmdStateUpdateProgress` still runs for its own #3217/
|
|
643
|
+
* #3233 withhold gates) is what let the two disagree when the auto-derived
|
|
644
|
+
* "current" milestone differs from the stored one.
|
|
645
|
+
*
|
|
646
|
+
* Perf note: this duplicates buildStateFrontmatter's own `getMilestoneInfo`
|
|
647
|
+
* (re-reads/re-parses ROADMAP.md) and `readGitHeadSha` (a `git rev-parse`
|
|
648
|
+
* subprocess spawn) — neither is memoized, unlike the phase/plan disk scan
|
|
649
|
+
* (`_diskScanCache`), which IS shared with the second `buildStateFrontmatter`
|
|
650
|
+
* call `readModifyWriteStateMd` makes below. Both non-cached calls therefore
|
|
651
|
+
* run twice per `state update-progress`.
|
|
652
|
+
*/
|
|
653
|
+
function computeUpdateProgressPreview(statePath, cwd) {
|
|
654
|
+
const preContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
655
|
+
const existingFm = extractFrontmatter(preContent, statePath);
|
|
656
|
+
const preBody = stripFrontmatter(preContent);
|
|
657
|
+
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
658
|
+
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
659
|
+
const progress = builtFm['progress'];
|
|
660
|
+
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
|
661
|
+
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
|
662
|
+
const totalPlans = progress && typeof progress['total_plans'] === 'number' ? progress['total_plans'] : null;
|
|
663
|
+
// A null percent is REACHABLE beyond the #3217/#3233 withholds the caller
|
|
664
|
+
// already applies — buildStateFrontmatter also nulls it via its own #1761
|
|
665
|
+
// milestone-unbounded guard, evaluated from `assertedMilestoneVersion`
|
|
666
|
+
// (an independent derivation, including a bare-version-token-in-prose
|
|
667
|
+
// fallback) rather than from `storedMilestone`/diskScope, so a STATE.md
|
|
668
|
+
// with no explicit `milestone:` field but a bare vX.Y token mentioned in
|
|
669
|
+
// ROADMAP prose can pass both of the caller's guards and still land here.
|
|
670
|
+
// Falling back to a locally-computed percent would reintroduce the exact
|
|
671
|
+
// #3583 defect for that case, so withhold instead — same shape as the
|
|
672
|
+
// caller's own no-op guards.
|
|
673
|
+
if (percent === null || completedPlans === null || totalPlans === null) {
|
|
674
|
+
return { withheld: true, reason: 'progress percent withheld by buildStateFrontmatter — STATE.md left unchanged' };
|
|
675
|
+
}
|
|
676
|
+
return { withheld: false, percent, completedPlans, totalPlans };
|
|
677
|
+
}
|
|
534
678
|
function cmdStateUpdateProgress(cwd, raw) {
|
|
535
679
|
const statePath = planningPaths(cwd).state;
|
|
536
680
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
537
681
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
538
682
|
return;
|
|
539
683
|
}
|
|
540
|
-
//
|
|
684
|
+
// Auto-derived scan across current-milestone phases (outside lock — read-only).
|
|
685
|
+
// Gates the #3217/#3233 withholds below ONLY — the reported completed/total
|
|
686
|
+
// counts come from computeUpdateProgressPreview's differently-scoped
|
|
687
|
+
// (stored-milestone) scan instead, so percent and completed/total can never
|
|
688
|
+
// disagree (#3583, finding 1).
|
|
541
689
|
const phasesDir = planningPaths(cwd).phases;
|
|
542
690
|
let totalPlans = 0;
|
|
543
|
-
let
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
691
|
+
let phaseScope = SCOPE.UNREADABLE;
|
|
692
|
+
{
|
|
693
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
694
|
+
// CURRENT milestone" — routed through the canonical owner instead of a
|
|
695
|
+
// hand-rolled readdirSync + isDirInMilestone filter (which also never
|
|
696
|
+
// excluded sentinels, unlike the owner). The owner already handles an
|
|
697
|
+
// absent phasesDir as a real empty, so the fs.existsSync guard folds
|
|
698
|
+
// into it.
|
|
699
|
+
const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
700
|
+
phaseScope = scope;
|
|
549
701
|
for (const dir of phaseDirs) {
|
|
550
|
-
const { planCount
|
|
702
|
+
const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
551
703
|
totalPlans += planCount;
|
|
552
|
-
totalSummaries += summaryCount;
|
|
553
704
|
}
|
|
554
705
|
}
|
|
555
|
-
|
|
706
|
+
// #3217 (ADR-3180 §7.6 rule 4): a non-COMPLETE scope means the counts
|
|
707
|
+
// above are not a trustworthy answer — do not write a percentage derived
|
|
708
|
+
// from them into STATE.md at all (A7). This is the write path, so
|
|
709
|
+
// "withhold" means "make no edit" rather than emitting a null value.
|
|
710
|
+
if (phaseScope !== SCOPE.COMPLETE) {
|
|
711
|
+
// #3217 finding 3 (decided: surface a warning, not silent-only
|
|
712
|
+
// disclosure): the JSON `reason` field alone is easy for a caller to
|
|
713
|
+
// never read, and STATE.md's Progress field goes stale with no
|
|
714
|
+
// user-visible signal beyond it. Mirrors the established
|
|
715
|
+
// `[gsd-tools] WARNING:` stderr convention this file already uses
|
|
716
|
+
// (stateReplaceFieldWithFallback above) for a comparable silent no-op.
|
|
717
|
+
process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
|
|
718
|
+
`STATE.md's Progress field was left unchanged.\n`);
|
|
719
|
+
output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false');
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// #3233: zero plans in the current-milestone phases means there is nothing to
|
|
723
|
+
// measure — most often the milestone was just closed and its phases archived
|
|
724
|
+
// (.planning/phases/ empty, but scope COMPLETE — "a real empty"). clampPercent
|
|
725
|
+
// maps 0/0 to 0%, which would clobber the shipped Progress record (e.g.
|
|
726
|
+
// [██████████] 100% → [░░░░░░░░░░] 0%). No-op instead, mirroring the
|
|
727
|
+
// scope-withhold above and computeProgressPercent's null-for-empty contract
|
|
728
|
+
// ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
|
|
729
|
+
// none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
|
|
730
|
+
if (totalPlans === 0) {
|
|
731
|
+
process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` +
|
|
732
|
+
`STATE.md's Progress field was left unchanged (milestone archived?).\n`);
|
|
733
|
+
output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// #3583: percent AND the completed/total counts reported alongside it both
|
|
737
|
+
// come from the SAME buildStateFrontmatter call (computeUpdateProgressPreview)
|
|
738
|
+
// — never from the auto-derived scan above, which exists only to gate the
|
|
739
|
+
// #3217/#3233 withholds and is scoped differently (no stored-milestone
|
|
740
|
+
// override), so reusing its counts here could report a percent that
|
|
741
|
+
// disagrees with its own completed/total.
|
|
742
|
+
const preview = computeUpdateProgressPreview(statePath, cwd);
|
|
743
|
+
if (preview.withheld) {
|
|
744
|
+
process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — ${preview.reason}\n`);
|
|
745
|
+
output({ updated: false, reason: preview.reason }, raw, 'false');
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
|
|
556
749
|
const barWidth = 10;
|
|
557
750
|
const filled = Math.round(percent / 100 * barWidth);
|
|
558
751
|
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
559
752
|
const progressStr = `[${bar}] ${percent}%`;
|
|
560
753
|
let updated = false;
|
|
561
|
-
const _totalPlans = totalPlans;
|
|
562
|
-
const _totalSummaries = totalSummaries;
|
|
563
754
|
readModifyWriteStateMd(statePath, (content) => {
|
|
564
755
|
// #2177: match against the BODY only. With /i the patterns below would
|
|
565
756
|
// otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
|
|
@@ -588,7 +779,7 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
588
779
|
return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
|
|
589
780
|
}, cwd);
|
|
590
781
|
if (updated) {
|
|
591
|
-
output({ updated: true, percent, completed:
|
|
782
|
+
output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
|
|
592
783
|
}
|
|
593
784
|
else {
|
|
594
785
|
output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
|
|
@@ -615,7 +806,20 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
615
806
|
output({ error: 'summary required' }, raw, undefined);
|
|
616
807
|
return;
|
|
617
808
|
}
|
|
618
|
-
|
|
809
|
+
// #3231/#3481: `--phase` omitted → resolve from the STATE.md being written, via
|
|
810
|
+
// the canonical ladder `state prune` uses. A decision entry is a permanent
|
|
811
|
+
// record, so a literal `[Phase ?]` written while `current_phase` sat three
|
|
812
|
+
// lines above the insertion point loses that decision's provenance for good.
|
|
813
|
+
// Explicit `--phase` still wins, and its path is untouched — the file is not
|
|
814
|
+
// even read. When no rung resolves, `?` is still written: an unknown phase
|
|
815
|
+
// stays visibly unknown rather than being guessed or defaulted to a number.
|
|
816
|
+
let phaseId = phase;
|
|
817
|
+
if (!phaseId) {
|
|
818
|
+
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
819
|
+
const fm = extractFrontmatter(rawState, statePath);
|
|
820
|
+
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
|
|
821
|
+
}
|
|
822
|
+
const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
|
|
619
823
|
let _added = false;
|
|
620
824
|
let created = false;
|
|
621
825
|
readModifyWriteStateMd(statePath, (content) => {
|
|
@@ -765,7 +969,20 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
765
969
|
const actionText = (action && action.trim()) || 'changed';
|
|
766
970
|
const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
|
|
767
971
|
const urgentText = urgent ? ' (URGENT)' : '';
|
|
768
|
-
|
|
972
|
+
// #3481: same treatment as add-decision's #3231 fix — `--phase` omitted →
|
|
973
|
+
// resolve from the STATE.md being written via the shared write-path ladder.
|
|
974
|
+
// A roadmap-evolution entry is a permanent record of why the roadmap changed
|
|
975
|
+
// shape, so a literal `Phase ?` written while `current_phase` sat in the
|
|
976
|
+
// frontmatter above the insertion point makes that trail unattributable.
|
|
977
|
+
// Explicit `--phase` still wins (the file is not even read on that path), and
|
|
978
|
+
// `?` is still written when nothing resolves — never a guess.
|
|
979
|
+
let phaseId = phase;
|
|
980
|
+
if (!phaseId) {
|
|
981
|
+
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
982
|
+
const fm = extractFrontmatter(rawState, statePath);
|
|
983
|
+
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
|
|
984
|
+
}
|
|
985
|
+
const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
|
|
769
986
|
let duplicate = false;
|
|
770
987
|
let created = false;
|
|
771
988
|
let subsectionCreated = false;
|
|
@@ -921,6 +1138,8 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
921
1138
|
const now = clock_cjs_1.realClock.nowIso();
|
|
922
1139
|
const updated = [];
|
|
923
1140
|
let sessionCreated = false;
|
|
1141
|
+
let preSyncContent = '';
|
|
1142
|
+
const divergedFields = [];
|
|
924
1143
|
readModifyWriteStateMd(statePath, (content) => {
|
|
925
1144
|
// Update Last session / Last Date
|
|
926
1145
|
let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
|
|
@@ -934,13 +1153,25 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
934
1153
|
updated.push('Last Date');
|
|
935
1154
|
}
|
|
936
1155
|
// Update Stopped at
|
|
1156
|
+
// #3374 Variant B: stateReplaceField returns the replaced string on any
|
|
1157
|
+
// label MATCH, including when the value is already the target. Pushing
|
|
1158
|
+
// 'Stopped At' on match alone reported a write that never changed a byte
|
|
1159
|
+
// (and that the #948 no-op guard may then discard entirely), leaving a
|
|
1160
|
+
// stale frontmatter stopped_at undetectable to the caller. Report only on
|
|
1161
|
+
// real change — and track the match separately so an identical value does
|
|
1162
|
+
// not read as "label missing" to the #944 DWIM insertion below (whose
|
|
1163
|
+
// section rewrite would reset an executor-authored resume file to None).
|
|
1164
|
+
let stoppedAtMatched = false;
|
|
937
1165
|
if (options.stopped_at) {
|
|
938
1166
|
result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
|
|
939
1167
|
if (!result)
|
|
940
1168
|
result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
|
|
941
1169
|
if (result) {
|
|
942
|
-
|
|
943
|
-
|
|
1170
|
+
stoppedAtMatched = true;
|
|
1171
|
+
if (result !== content) {
|
|
1172
|
+
content = result;
|
|
1173
|
+
updated.push('Stopped At');
|
|
1174
|
+
}
|
|
944
1175
|
}
|
|
945
1176
|
}
|
|
946
1177
|
// Update Resume File — only when the caller explicitly passed a value OR the
|
|
@@ -996,7 +1227,10 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
996
1227
|
// missing canonical fields are inserted while the heading and any prose are
|
|
997
1228
|
// preserved (#1101). Only append a brand-new section when NEITHER heading exists.
|
|
998
1229
|
const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
|
|
999
|
-
|
|
1230
|
+
// #3374: keyed on the label MATCH, not on updated[] — a matched-but-
|
|
1231
|
+
// identical value is already persisted on disk and must not trigger the
|
|
1232
|
+
// insertion rewrite below.
|
|
1233
|
+
const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
|
|
1000
1234
|
const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
|
|
1001
1235
|
const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
|
|
1002
1236
|
if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
|
|
@@ -1119,10 +1353,16 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1119
1353
|
updated.push('Resume File');
|
|
1120
1354
|
}
|
|
1121
1355
|
}
|
|
1356
|
+
preSyncContent = content;
|
|
1122
1357
|
return content;
|
|
1123
|
-
}, cwd);
|
|
1124
|
-
|
|
1125
|
-
|
|
1358
|
+
}, cwd, { divergedFields });
|
|
1359
|
+
// ADR-3408 §8.4 (D4): reconcile this command's own success list against the
|
|
1360
|
+
// bytes actually persisted (fix(#3351) generalized) and fold in any field
|
|
1361
|
+
// preservation restored that this transform never touched (#3345's
|
|
1362
|
+
// direction).
|
|
1363
|
+
const reconciledUpdated = reconcileReportedFields(statePath, preSyncContent, updated, divergedFields);
|
|
1364
|
+
if (reconciledUpdated.length > 0) {
|
|
1365
|
+
const result = { recorded: true, updated: reconciledUpdated };
|
|
1126
1366
|
if (sessionCreated)
|
|
1127
1367
|
result['created'] = true;
|
|
1128
1368
|
output(result, raw, 'true');
|
|
@@ -1152,6 +1392,32 @@ function matchSessionSection(body) {
|
|
|
1152
1392
|
?? (0, markdown_sectionizer_cjs_1.collectSection)(body, isSessionContinuity, { levelBounded: true });
|
|
1153
1393
|
return section ? section.body : null;
|
|
1154
1394
|
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Match the "Current Position" section body from a STATE.md body. #2956: this
|
|
1397
|
+
* is the Phase analogue of matchSessionSection. `Phase` canonically lives under
|
|
1398
|
+
* `## Current Position` (gsd-core/templates/state.md), so — like Stopped At /
|
|
1399
|
+
* Paused At under `## Session` — it must be extracted from THAT section, not
|
|
1400
|
+
* from the first `Phase:` / `**Phase:**` line anywhere in the body. Without the
|
|
1401
|
+
* scope, a historical `Phase:` line in an archive section silently overwrites
|
|
1402
|
+
* `current_phase` on every write, and because `current_phase` is routing input
|
|
1403
|
+
* for gsd-progress / --next the rewind routes work to the wrong phase.
|
|
1404
|
+
*
|
|
1405
|
+
* Level-flexible: the canonical template uses an h2 `## Current Position`, the
|
|
1406
|
+
* bootstrap template an h3 `### Current Position` (templates/state.md). Both
|
|
1407
|
+
* must match — mirroring how matchSessionSection recognises `## Session` and
|
|
1408
|
+
* `## Session Continuity`. Exact 'current position' text match (case-insensitive)
|
|
1409
|
+
* excludes unrelated headings. Built on the same `collectSection` seam as
|
|
1410
|
+
* matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
|
|
1411
|
+
* Returns the section body, or null (caller falls back to full-body search).
|
|
1412
|
+
*
|
|
1413
|
+
* The scoping logic now lives in state-document.cjs's `stateCurrentPositionSlice`
|
|
1414
|
+
* (the module that owns STATE.md field extraction) — this is a thin alias kept
|
|
1415
|
+
* for call-site stability. Two copies of this scope would be exactly the kind
|
|
1416
|
+
* of generative-fix divergence the repo's parity rule exists to prevent.
|
|
1417
|
+
*/
|
|
1418
|
+
function matchCurrentPositionSection(body) {
|
|
1419
|
+
return (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
|
|
1420
|
+
}
|
|
1155
1421
|
/**
|
|
1156
1422
|
* #2567: prevent a stale archive "Last activity:" line from overwriting a
|
|
1157
1423
|
* newer frontmatter value. `stateExtractField` matches the first body
|
|
@@ -1177,11 +1443,18 @@ function preferNewerLastActivity(existingFm, derivedFm) {
|
|
|
1177
1443
|
const derDate = derRaw.slice(0, 10);
|
|
1178
1444
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate))
|
|
1179
1445
|
return;
|
|
1446
|
+
// #3258: this guard now protects only `last_activity` (a `derive` row) against
|
|
1447
|
+
// the stale-archive regression (#2567). `last_activity_desc` used to be
|
|
1448
|
+
// restored here too (both the older-date and the #3052 same-date branches),
|
|
1449
|
+
// but that was a date-comparison rule — a DIFFERENT policy from the
|
|
1450
|
+
// `preserve-when-unchanged` row its FIELD_CLASSIFICATION entry declares.
|
|
1451
|
+
// Keeping both was two rules that could disagree. last_activity_desc is now
|
|
1452
|
+
// governed by exactly one rule: its table row, enforced by
|
|
1453
|
+
// applyStatePreservation's #1230 delta heuristic on the RMW path (where every
|
|
1454
|
+
// desc-preserving transition — planned-phase / advance / complete / milestone
|
|
1455
|
+
// — runs). The #3052 same-date contract still holds via that delta rule.
|
|
1180
1456
|
if (derDate < exDate) {
|
|
1181
1457
|
derivedFm['last_activity'] = exRaw;
|
|
1182
|
-
if (existingFm['last_activity_desc'] !== undefined) {
|
|
1183
|
-
derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
|
|
1184
|
-
}
|
|
1185
1458
|
}
|
|
1186
1459
|
}
|
|
1187
1460
|
function parseProsePhaseField(value) {
|
|
@@ -1193,6 +1466,76 @@ function parseProsePhaseField(value) {
|
|
|
1193
1466
|
// current_phase instead of clobbering it.
|
|
1194
1467
|
return parsePhaseFromProse(value);
|
|
1195
1468
|
}
|
|
1469
|
+
function resolveStatePhase(fm, body) {
|
|
1470
|
+
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
|
|
1471
|
+
const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
|
|
1472
|
+
const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
|
|
1473
|
+
const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
|
|
1474
|
+
const sources = {
|
|
1475
|
+
frontmatter: parseProsePhaseField(frontmatterRaw).phase,
|
|
1476
|
+
legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
|
|
1477
|
+
current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
|
|
1478
|
+
};
|
|
1479
|
+
const prosePhase = parseProsePhaseField(currentPositionRaw);
|
|
1480
|
+
return {
|
|
1481
|
+
phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
|
|
1482
|
+
name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
|
|
1483
|
+
?? (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase Name').value
|
|
1484
|
+
?? prosePhase.name,
|
|
1485
|
+
sources,
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Resolve a STATE.md's own current phase id from the document itself — the
|
|
1490
|
+
* WRITE-PATH ladder shared by `cmdStateAddDecision` (#3231) and
|
|
1491
|
+
* `cmdStateAddRoadmapEvolution` (#3481), extracted from the ladder
|
|
1492
|
+
* `cmdStatePrune` already ran (#1760).
|
|
1493
|
+
*
|
|
1494
|
+
* The rungs are the canonical ones owned by state-document.cjs's
|
|
1495
|
+
* `stateFieldValue` (#3187, ADR-3180 §7.7): frontmatter `current_phase` → body
|
|
1496
|
+
* `Current Phase` field → prose `Phase: X of Y` scoped to `## Current
|
|
1497
|
+
* Position`.
|
|
1498
|
+
*
|
|
1499
|
+
* #1776: the prose rung stays scoped to `## Current Position`. Over the whole
|
|
1500
|
+
* body, `stateExtractField`'s pipe-table fallback matches any `| Phase | N |`
|
|
1501
|
+
* row — e.g. a historical verification table — and would resolve a stale phase.
|
|
1502
|
+
* Frontmatter and the explicit `Current Phase` field are unambiguous, so they
|
|
1503
|
+
* stay document-wide. `cmdStateSnapshot` deliberately keeps the looser
|
|
1504
|
+
* whole-body fallback for its own prose rung and is not routed through here.
|
|
1505
|
+
*
|
|
1506
|
+
* Returns the id exactly as written, NOT parsed to a number: phase ids are not
|
|
1507
|
+
* always integers (`11-01` and `04.1` are both real). Callers needing an
|
|
1508
|
+
* integer parse it themselves. Returns null when no rung carries a value — a
|
|
1509
|
+
* genuinely absent phase is a real answer (§7.7 behavior table row 4), and
|
|
1510
|
+
* callers must render it as unknown rather than guess one.
|
|
1511
|
+
*
|
|
1512
|
+
* NOT the same function as `resolveStatePhase` above (#3208), and deliberately
|
|
1513
|
+
* not routed through it — the difference is one line and it is the whole point:
|
|
1514
|
+
*
|
|
1515
|
+
* resolveStatePhase: matchCurrentPositionSection(body) ?? body
|
|
1516
|
+
* resolveCurrentPhaseId: null when the section is absent
|
|
1517
|
+
*
|
|
1518
|
+
* That `?? body` fallback is exactly the #1776 hazard. With no `## Current
|
|
1519
|
+
* Position` section, the prose rung widens to the entire document, where
|
|
1520
|
+
* `stateExtractField`'s pipe-table fallback matches any `| Phase | N |` row —
|
|
1521
|
+
* a historical verification table included — and resolves a stale phase.
|
|
1522
|
+
*
|
|
1523
|
+
* `resolveStatePhase`'s callers (`cmdStateSnapshot`, `cmdStateValidate`) READ
|
|
1524
|
+
* and report; a stale guess there is a wrong line in output a human is already
|
|
1525
|
+
* looking at. This function's callers WRITE: `cmdStateAddDecision` and
|
|
1526
|
+
* `cmdStateAddRoadmapEvolution` persist the result into records that outlive
|
|
1527
|
+
* the session, and `cmdStatePrune` decides what to delete from it. A wrong
|
|
1528
|
+
* phase there is durable and silent, so the write path takes the strict rung
|
|
1529
|
+
* and renders `?` rather than guessing.
|
|
1530
|
+
*
|
|
1531
|
+
* Reconcile the two only by giving `resolveStatePhase` an explicit scope
|
|
1532
|
+
* parameter — never by pointing this at it and dropping the difference.
|
|
1533
|
+
*/
|
|
1534
|
+
function resolveCurrentPhaseId(fm, body) {
|
|
1535
|
+
const positionSection = sliceCurrentPositionSection(body);
|
|
1536
|
+
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value).phase : null;
|
|
1537
|
+
return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
|
|
1538
|
+
}
|
|
1196
1539
|
function parseProseLastActivityField(value) {
|
|
1197
1540
|
if (!value)
|
|
1198
1541
|
return { date: null, description: null };
|
|
@@ -1218,35 +1561,34 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1218
1561
|
// reported under a content digest — STATE.md is one of the artefacts epic #1879 is about.
|
|
1219
1562
|
const fm = extractFrontmatter(content, statePath);
|
|
1220
1563
|
const body = stripFrontmatter(content);
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
// Returns null for missing, null/undefined, or empty-after-trim values so
|
|
1225
|
-
// the caller falls back to body extraction.
|
|
1226
|
-
const fmScalar = (key) => {
|
|
1227
|
-
const v = fm[key];
|
|
1228
|
-
if (v === null || v === undefined)
|
|
1229
|
-
return null;
|
|
1230
|
-
if (typeof v === 'string')
|
|
1231
|
-
return v.trim() || null;
|
|
1232
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
1233
|
-
return String(v);
|
|
1234
|
-
return null;
|
|
1235
|
-
};
|
|
1564
|
+
// #3187: frontmatter-scalar-then-body-field precedence is owned by
|
|
1565
|
+
// state-document.cjs's `stateFieldValue` (ADR-3180 §7.7) — this function no
|
|
1566
|
+
// longer holds its own fmScalar ladder.
|
|
1236
1567
|
// Extract basic fields — frontmatter keys take precedence over body
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1568
|
+
// #2956: scope `Phase` extraction to ## Current Position so a historical
|
|
1569
|
+
// Phase: / **Phase:** line in an archive section cannot overwrite the current
|
|
1570
|
+
// value. Phase canonically lives in ## Current Position (templates/state.md),
|
|
1571
|
+
// so it is scopeable exactly like Stopped At under ## Session. Fall back to
|
|
1572
|
+
// full-body search only when no ## Current Position section exists, so files
|
|
1573
|
+
// with no section heading keep their current behaviour.
|
|
1574
|
+
const resolvedPhase = resolveStatePhase(fm, body);
|
|
1575
|
+
const currentPhase = resolvedPhase.phase;
|
|
1576
|
+
const currentPhaseName = resolvedPhase.name;
|
|
1577
|
+
const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
|
|
1578
|
+
const currentPlan = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_plan', 'Current Plan').value;
|
|
1579
|
+
const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
|
|
1580
|
+
const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value;
|
|
1581
|
+
const progressRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'progress', 'Progress').value;
|
|
1582
|
+
const rawLastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Last Activity').value ?? (0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Last activity').value;
|
|
1246
1583
|
const proseLastActivity = parseProseLastActivityField(rawLastActivity);
|
|
1247
|
-
const lastActivity =
|
|
1248
|
-
const lastActivityDesc =
|
|
1249
|
-
|
|
1584
|
+
const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', null).value ?? proseLastActivity.date ?? rawLastActivity;
|
|
1585
|
+
const lastActivityDesc = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity_desc', 'Last Activity Description').value ?? proseLastActivity.description;
|
|
1586
|
+
// #2956: Paused At canonically lives in ## Session (see the comment above
|
|
1587
|
+
// preferNewerLastActivity and the write seam in buildStateFrontmatter). The
|
|
1588
|
+
// write seam already scopes it to ## Session; this read seam must agree, so a
|
|
1589
|
+
// stale "Paused At:" in a Session Continuity Archive cannot win here either.
|
|
1590
|
+
const sessionScope = matchSessionSection(body) ?? body;
|
|
1591
|
+
const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
|
|
1250
1592
|
// Parse numeric fields
|
|
1251
1593
|
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
1252
1594
|
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
@@ -1324,25 +1666,11 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1324
1666
|
output(result, raw, undefined);
|
|
1325
1667
|
}
|
|
1326
1668
|
// ─── State Frontmatter Sync ──────────────────────────────────────────────────
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
* each map to one key. For a directory, extract its phase token first.
|
|
1333
|
-
*
|
|
1334
|
-
* Stripping the project-code prefix is GSD's canonical phase identity (a
|
|
1335
|
-
* project_code is a display prefix; normalizePhaseName / phaseTokenMatches treat
|
|
1336
|
-
* `CK-01` and `01` as the same phase, which is what lets a prefixed dir match a
|
|
1337
|
-
* bare ROADMAP token). A consistent project uses one scheme, so a bare numeric
|
|
1338
|
-
* and a same-suffix project-code phase never coexist in one milestone.
|
|
1339
|
-
*/
|
|
1340
|
-
function phaseKeyFromToken(token) {
|
|
1341
|
-
return normalizePhaseName(token).toUpperCase();
|
|
1342
|
-
}
|
|
1343
|
-
function phaseKeyFromDir(dir) {
|
|
1344
|
-
return phaseKeyFromToken(extractPhaseToken(dir));
|
|
1345
|
-
}
|
|
1669
|
+
// `phaseKeyFromToken` / `phaseKeyFromDir` — the canonical key for matching a
|
|
1670
|
+
// ROADMAP phase token against an on-disk phase directory — moved to the
|
|
1671
|
+
// phase-id owner module in #2562 so every consumer derives BOTH sides of a
|
|
1672
|
+
// phase comparison from the same function (see phase-id.cts). Imported at the
|
|
1673
|
+
// top of this file; call sites below are unchanged.
|
|
1346
1674
|
/**
|
|
1347
1675
|
* Extract the set of retired/folded phase keys from a ROADMAP milestone scope
|
|
1348
1676
|
* (#1514). A retired phase is struck through with GFM strikethrough,
|
|
@@ -1386,8 +1714,15 @@ function extractRetiredPhaseNumbers(scope) {
|
|
|
1386
1714
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
1387
1715
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1388
1716
|
*/
|
|
1389
|
-
function buildStateFrontmatter(bodyContent, cwd) {
|
|
1390
|
-
|
|
1717
|
+
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases) {
|
|
1718
|
+
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
|
1719
|
+
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
|
1720
|
+
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
|
1721
|
+
// without the scope, a historical Phase: / **Phase:** line in an archive
|
|
1722
|
+
// section overwrites current_phase here, and the next read surfaces it. Fall
|
|
1723
|
+
// back to full-body search when no ## Current Position section exists.
|
|
1724
|
+
const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
|
|
1725
|
+
const prosePhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(currentPositionScope, 'Phase'));
|
|
1391
1726
|
const currentPhase = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase') ?? prosePhase.phase;
|
|
1392
1727
|
const currentPhaseName = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Phase Name') ?? prosePhase.name;
|
|
1393
1728
|
const currentPlan = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Current Plan');
|
|
@@ -1413,14 +1748,46 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1413
1748
|
const pausedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Paused At');
|
|
1414
1749
|
let milestone = null;
|
|
1415
1750
|
let milestoneName = null;
|
|
1751
|
+
// #1761 regression fix (#3216): the milestone STATE.md actually ASSERTS,
|
|
1752
|
+
// independent of whether getMilestoneInfo's identity scope is COMPLETE.
|
|
1753
|
+
// Needed below by the disk-scan block's `isMilestoneBoundedInRoadmap` guard
|
|
1754
|
+
// — that check answers "is the ASSERTED version bounded to a versioned
|
|
1755
|
+
// ROADMAP heading", a different question from "is the identity trustworthy
|
|
1756
|
+
// enough to persist" (`milestone` above). Conflating the two regressed
|
|
1757
|
+
// #1761: when a real STATE `milestone:` value has no matching ROADMAP
|
|
1758
|
+
// heading, `info.scope` is never COMPLETE (rightly — there's no curated
|
|
1759
|
+
// name to persist), but the version was still genuinely asserted and the
|
|
1760
|
+
// bounded check must still run on it, or the guard silently no-ops and
|
|
1761
|
+
// `state json` reports a conflated whole-document total_phases/percent.
|
|
1762
|
+
let assertedMilestoneVersion = null;
|
|
1416
1763
|
if (cwd) {
|
|
1417
1764
|
// DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
|
|
1418
1765
|
// try/catch (roadmap-parser.cts) that already swallows every internal
|
|
1419
|
-
// failure and always returns a
|
|
1766
|
+
// failure and always returns a ScopedResult — it never throws, so this
|
|
1420
1767
|
// wrapper could never be triggered.
|
|
1768
|
+
// #3216 (ADR-3180 §7.2 rule 6): this is the #3197 disk-write path. Rule 6
|
|
1769
|
+
// draws the line at the FIELD, not the scope as a whole — "a version known
|
|
1770
|
+
// but no name resolvable is TRUNCATED carrying {version, name: null} — the
|
|
1771
|
+
// version is a real answer, the name is a non-answer, and collapsing the
|
|
1772
|
+
// two is the failure this contract exists to prevent." So `milestone`
|
|
1773
|
+
// (the version) is written whenever COMPLETE or TRUNCATED — both carry a
|
|
1774
|
+
// genuine version per rule 6 — while `milestoneName` is written only on
|
|
1775
|
+
// COMPLETE, since TRUNCATED's name is by definition unresolved and must
|
|
1776
|
+
// never be fabricated. UNSCOPED/UNREADABLE have no real version either
|
|
1777
|
+
// way, so both stay null there. This mirrors cmdCommit (src/commands.cts),
|
|
1778
|
+
// which accepts COMPLETE or TRUNCATED for the same reason (the version is
|
|
1779
|
+
// real), and deliberately diverges from archivePhaseDirectories
|
|
1780
|
+
// (src/milestone.cts), which demands COMPLETE only because it uses the
|
|
1781
|
+
// value as a filesystem path component and a TRUNCATED version is not
|
|
1782
|
+
// safe to use there.
|
|
1421
1783
|
const info = getMilestoneInfo(cwd);
|
|
1422
|
-
|
|
1423
|
-
|
|
1784
|
+
assertedMilestoneVersion = info.value ? info.value.version : null;
|
|
1785
|
+
if ((info.scope === SCOPE.COMPLETE || info.scope === SCOPE.TRUNCATED) && info.value) {
|
|
1786
|
+
milestone = info.value.version;
|
|
1787
|
+
}
|
|
1788
|
+
if (info.scope === SCOPE.COMPLETE && info.value) {
|
|
1789
|
+
milestoneName = info.value.name;
|
|
1790
|
+
}
|
|
1424
1791
|
}
|
|
1425
1792
|
let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
1426
1793
|
let completedPhases = null;
|
|
@@ -1429,6 +1796,14 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1429
1796
|
// #1761 read-path: set from cached.milestoneBounded inside the disk-scan
|
|
1430
1797
|
// block; consumed at the percent computation to mirror the cmdStateSync guard.
|
|
1431
1798
|
let milestoneUnbounded = false;
|
|
1799
|
+
// #3217 (ADR-3180 §7.6 rule 4, finding 1): the real listMilestonePhaseDirs
|
|
1800
|
+
// scope for the disk-scanned counts below, set from cached.phaseDirScope
|
|
1801
|
+
// when a fresh disk scan runs. SCOPE.COMPLETE is the correct default here
|
|
1802
|
+
// — NOT a rule-4 hardcode — for the cases where no disk scan happens at all
|
|
1803
|
+
// (no cwd, or phasesDir absent): totalPhases/totalPlans then come straight
|
|
1804
|
+
// from the pre-existing frontmatter fields parsed above, a path this phase
|
|
1805
|
+
// does not touch and which predates listMilestonePhaseDirs entirely.
|
|
1806
|
+
let diskScope = SCOPE.COMPLETE;
|
|
1432
1807
|
if (cwd) {
|
|
1433
1808
|
try {
|
|
1434
1809
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -1453,14 +1828,19 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1453
1828
|
}
|
|
1454
1829
|
}
|
|
1455
1830
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1831
|
+
// #3017: scope the milestone filter to the STORED milestone when available,
|
|
1832
|
+
// so a state.* write doesn't auto-derive (and mis-bind) to a different
|
|
1833
|
+
// milestone's heading and clobber the stored value + progress counts.
|
|
1834
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
1835
|
+
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
1836
|
+
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
1837
|
+
// (which also never excluded sentinels, unlike the owner).
|
|
1838
|
+
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
|
|
1460
1839
|
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1461
1840
|
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1462
|
-
// de-duplicate by normalized phase number keeping
|
|
1463
|
-
//
|
|
1841
|
+
// de-duplicate by normalized phase number keeping exactly one dir
|
|
1842
|
+
// per key (deterministic tie-break: see #3355 below). This prevents
|
|
1843
|
+
// double-counting (e.g. two "Phase 1" dirs).
|
|
1464
1844
|
const seenPhaseNums = new Map(); // normalizedNum -> dirName
|
|
1465
1845
|
for (const dir of allMatchingDirs) {
|
|
1466
1846
|
// #1514: a retired/folded phase keeps a directory but no completion
|
|
@@ -1469,22 +1849,35 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1469
1849
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
1470
1850
|
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
|
|
1471
1851
|
continue;
|
|
1472
|
-
//
|
|
1473
|
-
|
|
1474
|
-
|
|
1852
|
+
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
1853
|
+
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
1854
|
+
// diverged from extractPhaseToken/phaseKeyFromDir on
|
|
1855
|
+
// project-code-prefixed dirs (whole dirname fell through as the key,
|
|
1856
|
+
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
1857
|
+
// multi-segment milestone dirs. Same key surface used two lines
|
|
1858
|
+
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
1859
|
+
const key = phaseKeyFromDir(dir);
|
|
1475
1860
|
if (!seenPhaseNums.has(key)) {
|
|
1476
1861
|
seenPhaseNums.set(key, dir);
|
|
1477
1862
|
}
|
|
1478
1863
|
else {
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1864
|
+
// #3355: the survivor of a same-milestone collision must be
|
|
1865
|
+
// chosen from repository CONTENT, never from filesystem state.
|
|
1866
|
+
// The pre-#3355 tie-break was `mtimeMs` — a checkout-order
|
|
1867
|
+
// signal — so two byte-identical checkouts of the same commit
|
|
1868
|
+
// that wrote the colliding dirs in a different order picked
|
|
1869
|
+
// different survivors, and progress.total_plans /
|
|
1870
|
+
// completed_plans drifted across clones and CI runs. The
|
|
1871
|
+
// directory NAME is git-tracked content and a total order, so
|
|
1872
|
+
// the lexicographically-first dir wins deterministically. The
|
|
1873
|
+
// collision is still a project-level defect (duplicate phase
|
|
1874
|
+
// number in scope), so it is surfaced on stderr instead of
|
|
1875
|
+
// being silently resolved. The Bug #2445 invariant — exactly
|
|
1876
|
+
// one survivor per normalized phase number — is unchanged.
|
|
1877
|
+
const incumbent = seenPhaseNums.get(key);
|
|
1878
|
+
const survivor = dir < incumbent ? dir : incumbent;
|
|
1879
|
+
seenPhaseNums.set(key, survivor);
|
|
1880
|
+
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`);
|
|
1488
1881
|
}
|
|
1489
1882
|
}
|
|
1490
1883
|
const phaseDirs = [...seenPhaseNums.values()];
|
|
@@ -1493,10 +1886,18 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1493
1886
|
let diskCompletedPhases = 0;
|
|
1494
1887
|
for (const dir of phaseDirs) {
|
|
1495
1888
|
const phaseDir = node_path_1.default.join(phasesDir, dir);
|
|
1496
|
-
const { planCount, summaryCount
|
|
1889
|
+
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
|
|
1497
1890
|
diskTotalPlans += planCount;
|
|
1498
1891
|
diskTotalSummaries += summaryCount;
|
|
1499
|
-
|
|
1892
|
+
// ADR-3180 §7.4 (#3186, #2957 disk-strict): "which phases are
|
|
1893
|
+
// complete" is the completion question, routed through the single
|
|
1894
|
+
// canonical owner (isPhaseComplete, src/verification.cts) — NOT
|
|
1895
|
+
// scanPhasePlans's own `completed` field, which only answers "are
|
|
1896
|
+
// all plans summarized" (a different question; see plan-scan.cts's
|
|
1897
|
+
// own comment on that field). Folding this consumer onto the raw
|
|
1898
|
+
// summaries-met flag was the exact "consolidate two of three and
|
|
1899
|
+
// leave the third" gap §7.4's forcing function rules out.
|
|
1900
|
+
if (isPhaseComplete(phaseDir).value.complete)
|
|
1500
1901
|
diskCompletedPhases++;
|
|
1501
1902
|
}
|
|
1502
1903
|
// Count phase headings from ROADMAP using a digit-containing pattern
|
|
@@ -1513,8 +1914,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1513
1914
|
// Only count tokens that contain at least one digit — excludes
|
|
1514
1915
|
// pure-word section headings (Overview, Details) while keeping
|
|
1515
1916
|
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
1516
|
-
// Also exclude
|
|
1517
|
-
|
|
1917
|
+
// Also exclude sentinel phases (0 and 999.x backlog).
|
|
1918
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1919
|
+
if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
|
|
1518
1920
|
continue;
|
|
1519
1921
|
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
1520
1922
|
// exclude them from the denominator (they can never be completed).
|
|
@@ -1532,9 +1934,22 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1532
1934
|
// phase-dir count only, and mark unbounded so percent is skipped
|
|
1533
1935
|
// downstream (mirrors the sync write-path guard).
|
|
1534
1936
|
let milestoneBounded = true;
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1937
|
+
// #3216 fix (#1761 regression): use `assertedMilestoneVersion` —
|
|
1938
|
+
// the version STATE.md actually asserts — not the scope-gated
|
|
1939
|
+
// `milestone`. `milestone` is null on any non-COMPLETE identity
|
|
1940
|
+
// scope (deliberately, so a non-trustworthy identity never
|
|
1941
|
+
// persists), but a real asserted version with no matching
|
|
1942
|
+
// ROADMAP heading is EXACTLY the unbounded case this guard exists
|
|
1943
|
+
// to catch; gating on `milestone` skipped the guard entirely and
|
|
1944
|
+
// let the whole-document roadmapPhaseCount conflate sibling
|
|
1945
|
+
// milestones again.
|
|
1946
|
+
if (assertedMilestoneVersion && roadmapRaw !== null) {
|
|
1947
|
+
// #3184: routed through the single owner (roadmap-parser.cjs)
|
|
1948
|
+
// instead of a hand-rolled, unbounded-substring re-derivation —
|
|
1949
|
+
// the prior inline regex had no boundary assertion after the
|
|
1950
|
+
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
|
|
1951
|
+
// defect, design row 17).
|
|
1952
|
+
milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
|
|
1538
1953
|
}
|
|
1539
1954
|
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
|
|
1540
1955
|
// at all — only Phase headings) from a MILESTONED-but-unbounded one
|
|
@@ -1542,27 +1957,85 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1542
1957
|
// On a flat roadmap the whole-doc count is correct (no sibling milestones to
|
|
1543
1958
|
// conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
|
|
1544
1959
|
// so fall back to phaseDirs.length.
|
|
1545
|
-
|
|
1546
|
-
|
|
1960
|
+
// #3184: routed through the single owner (roadmap-parser.cjs) —
|
|
1961
|
+
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
|
|
1962
|
+
// version-token requirement); see hasMilestoneSectioning's own
|
|
1963
|
+
// doc comment for why that distinction is load-bearing.
|
|
1964
|
+
const roadmapHasMilestoneSectioning = roadmapRaw !== null
|
|
1965
|
+
&& hasMilestoneSectioning(roadmapRaw);
|
|
1547
1966
|
const safeToUseRoadmapCount = milestoneBounded
|
|
1548
|
-
|| (roadmapPhaseCount > 0 && !
|
|
1967
|
+
|| (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning);
|
|
1968
|
+
// #3354: the milestoned-but-unbounded sibling of the #2828/#3204
|
|
1969
|
+
// shapes. The whole-document roadmapPhaseCount is rightly rejected
|
|
1970
|
+
// above (it would conflate sibling milestones, #1761), but the
|
|
1971
|
+
// on-disk phase-dir count is NOT an authoritative substitute for
|
|
1972
|
+
// the rejected total either — it counts only the current
|
|
1973
|
+
// milestone's realized directories (25 declared → 4 written in the
|
|
1974
|
+
// issue's report), silently shrinking progress.total_phases on
|
|
1975
|
+
// every STATE.md write. Mirror the branch's own percent withhold
|
|
1976
|
+
// (milestoneUnbounded below): return a null sentinel so the caller
|
|
1977
|
+
// keeps the pre-existing stored value instead of writing the
|
|
1978
|
+
// substitute, and warn on stderr naming the unbounded token so the
|
|
1979
|
+
// operator can curate the ROADMAP heading or the STATE assertion.
|
|
1980
|
+
// The degenerate un-sectioned zero-heading case keeps the
|
|
1981
|
+
// phaseDirs.length fallback — with nothing declared anywhere else,
|
|
1982
|
+
// the disk count is the only source and remains correct.
|
|
1983
|
+
const milestonedButUnbounded = !milestoneBounded && roadmapHasMilestoneSectioning;
|
|
1984
|
+
if (milestonedButUnbounded) {
|
|
1985
|
+
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries multiple milestone sections; the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354)\n`);
|
|
1986
|
+
}
|
|
1987
|
+
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
|
1988
|
+
// absent/unreadable the #549 heading counter never ran (roadmapScope
|
|
1989
|
+
// stayed null), `milestoneBounded` is vacuously true (its gate requires
|
|
1990
|
+
// roadmapRaw), and the dir count — which only ever counts phases that
|
|
1991
|
+
// have STARTED — would be persisted as progress.total_phases by every
|
|
1992
|
+
// state.* write. A STATE that asserts a milestone (storedMilestone —
|
|
1993
|
+
// getMilestoneInfo is useless here, it reads the roadmap that is
|
|
1994
|
+
// absent) declared a total somewhere; keep the stored frontmatter
|
|
1995
|
+
// value instead. Without an asserted milestone (fresh project,
|
|
1996
|
+
// pre-roadmap) the disk count is still the only source and stays
|
|
1997
|
+
// authoritative (the #3354 doctrine's degenerate case).
|
|
1998
|
+
const roadmapAbsentWithAssertedMilestone = roadmapRaw === null &&
|
|
1999
|
+
typeof storedMilestone === 'string' &&
|
|
2000
|
+
storedMilestone.trim() !== '';
|
|
2001
|
+
if (roadmapAbsentWithAssertedMilestone) {
|
|
2002
|
+
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 progress.total_phases is left at its stored value. (#3573)\n`);
|
|
2003
|
+
}
|
|
1549
2004
|
return {
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
2005
|
+
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
|
2006
|
+
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
|
2007
|
+
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
|
|
2008
|
+
// vacuously true (its gate requires roadmapRaw), so the safe-count
|
|
2009
|
+
// arm would otherwise swallow the withhold.
|
|
2010
|
+
totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
|
|
2011
|
+
? null
|
|
2012
|
+
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
|
1553
2013
|
milestoneBounded,
|
|
1554
2014
|
completedPhases: diskCompletedPhases,
|
|
1555
2015
|
totalPlans: diskTotalPlans,
|
|
1556
2016
|
completedPlans: diskTotalSummaries,
|
|
2017
|
+
phaseDirScope,
|
|
1557
2018
|
};
|
|
1558
2019
|
})();
|
|
1559
2020
|
_diskScanCache.set(cwd, cached);
|
|
1560
2021
|
}
|
|
1561
|
-
|
|
2022
|
+
// #3354: cached.totalPhases === null is the milestoned-but-unbounded
|
|
2023
|
+
// WITHHOLD sentinel — the scan refused to substitute the dir count for
|
|
2024
|
+
// a rejected whole-document total, so keep the pre-existing value:
|
|
2025
|
+
// the stored frontmatter total when the caller can supply it, else the
|
|
2026
|
+
// body "Total Phases" annotation already parsed above, else leave null
|
|
2027
|
+
// (the key is omitted from the progress block).
|
|
2028
|
+
if (cached.totalPhases !== null) {
|
|
2029
|
+
totalPhases = cached.totalPhases;
|
|
2030
|
+
}
|
|
2031
|
+
else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
|
|
2032
|
+
totalPhases = storedTotalPhases;
|
|
2033
|
+
}
|
|
1562
2034
|
completedPhases = cached.completedPhases;
|
|
1563
2035
|
totalPlans = cached.totalPlans;
|
|
1564
2036
|
completedPlans = cached.completedPlans;
|
|
1565
2037
|
milestoneUnbounded = cached.milestoneBounded === false;
|
|
2038
|
+
diskScope = cached.phaseDirScope;
|
|
1566
2039
|
}
|
|
1567
2040
|
/* best-effort (#2245 audit): this is a READ path building STATE.md's
|
|
1568
2041
|
* display frontmatter. The real throw source is fs.readdirSync(phasesDir)
|
|
@@ -1579,17 +2052,66 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1579
2052
|
// ROADMAP-declared-but-unrealized future phases cap the reported completion
|
|
1580
2053
|
// instead of a false 100% from plan-only coverage (#3242 Bug B).
|
|
1581
2054
|
// Falls back to the body Progress: field only when no plan files exist on disk.
|
|
1582
|
-
|
|
2055
|
+
// #3217 (ADR-3180 §7.6 rule 4, finding 1): computeProgressPercent requires
|
|
2056
|
+
// a `Scope` for its own rule-4 gate. `diskScope` is the real
|
|
2057
|
+
// `listMilestonePhaseDirs` scope threaded through `_diskScanCache`
|
|
2058
|
+
// (`phaseDirScope` above) when a fresh disk scan ran — an UNREADABLE
|
|
2059
|
+
// phases dir now withholds here exactly as it does at every sibling
|
|
2060
|
+
// surface, closing the cross-surface disagreement the isolated review
|
|
2061
|
+
// caught. When no disk scan ran at all (no cwd, or phasesDir absent)
|
|
2062
|
+
// `diskScope` keeps its SCOPE.COMPLETE default, preserving this
|
|
2063
|
+
// function's pre-existing behavior on that (unrelated, pre-dating
|
|
2064
|
+
// listMilestonePhaseDirs) fallback path. This call site also keeps its own
|
|
2065
|
+
// orthogonal `milestoneUnbounded` null-out below (#1761) — a different
|
|
2066
|
+
// guard (ROADMAP heading boundedness, not disk readability).
|
|
2067
|
+
let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases, diskScope);
|
|
1583
2068
|
// #1761 read-path: when the milestone can't be bounded, percent would be
|
|
1584
2069
|
// derived from a conflated/understated total — skip it (mirror cmdStateSync).
|
|
1585
2070
|
if (milestoneUnbounded)
|
|
1586
2071
|
progressPercent = null;
|
|
1587
|
-
|
|
2072
|
+
// #3217 finding 1 (follow-on): a non-COMPLETE diskScope must withhold the
|
|
2073
|
+
// percentage EVERYWHERE, including this prose fallback — without the
|
|
2074
|
+
// `diskScope === SCOPE.COMPLETE` guard, a stale/existing "Progress: N%"
|
|
2075
|
+
// body line would silently defeat computeProgressPercent's rule-4 null,
|
|
2076
|
+
// re-introducing a rendered percentage on the exact scope this phase
|
|
2077
|
+
// withholds for (this is how the reviewer's UNREADABLE-phases fixture
|
|
2078
|
+
// could still surface a number even after the scope threading above).
|
|
2079
|
+
if (progressPercent === null && progressRaw && !milestoneUnbounded && diskScope === SCOPE.COMPLETE) {
|
|
1588
2080
|
const pctMatch = progressRaw.match(/(\d+)%/);
|
|
1589
2081
|
if (pctMatch)
|
|
1590
2082
|
progressPercent = parseInt(pctMatch[1], 10);
|
|
1591
2083
|
}
|
|
1592
|
-
|
|
2084
|
+
let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
|
|
2085
|
+
// #3578: normalizeStateStatus matches 'complete' as a case-insensitive
|
|
2086
|
+
// SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
|
|
2087
|
+
// the body (`Phase ${N} complete`) collapses to the milestone-level
|
|
2088
|
+
// 'completed' status even when other phases remain open. Phase-level
|
|
2089
|
+
// prose must never decide milestone-level status — completedPhases /
|
|
2090
|
+
// totalPhases / diskScope, already derived above from a disk scan, are
|
|
2091
|
+
// the authority on whether the MILESTONE is actually done. Only override
|
|
2092
|
+
// when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
|
|
2093
|
+
// raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
|
|
2094
|
+
// pattern below deliberately excludes "All phases complete" (no `\S+`
|
|
2095
|
+
// phase token) and milestone-close prose like "v1.0 milestone complete"
|
|
2096
|
+
// (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
|
|
2097
|
+
// disk scope, both counts are finite numbers, and a positive
|
|
2098
|
+
// denominator) and affirmatively disagree with 'completed'. In every
|
|
2099
|
+
// other case normalizedStatus is left exactly as normalizeStateStatus
|
|
2100
|
+
// returned it.
|
|
2101
|
+
if (normalizedStatus === 'completed' &&
|
|
2102
|
+
typeof status === 'string' &&
|
|
2103
|
+
/^\s*phase\s+\S+\s+complete\s*$/i.test(status) &&
|
|
2104
|
+
diskScope === SCOPE.COMPLETE &&
|
|
2105
|
+
// #1761: an unbounded milestone yields a conflated/understated total — the
|
|
2106
|
+
// same authority that nulls progressPercent above. Without this, a bad
|
|
2107
|
+
// denominator could demote a genuinely-complete milestone.
|
|
2108
|
+
!milestoneUnbounded &&
|
|
2109
|
+
typeof completedPhases === 'number' && Number.isFinite(completedPhases) &&
|
|
2110
|
+
typeof totalPhases === 'number' && Number.isFinite(totalPhases) &&
|
|
2111
|
+
totalPhases > 0 &&
|
|
2112
|
+
completedPhases < totalPhases) {
|
|
2113
|
+
normalizedStatus = 'executing';
|
|
2114
|
+
}
|
|
1593
2115
|
const fm = { gsd_state_version: '1.0' };
|
|
1594
2116
|
if (milestone)
|
|
1595
2117
|
fm['milestone'] = milestone;
|
|
@@ -1611,6 +2133,13 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1611
2133
|
fm['last_activity'] = lastActivity;
|
|
1612
2134
|
if (lastActivityDesc)
|
|
1613
2135
|
fm['last_activity_desc'] = lastActivityDesc;
|
|
2136
|
+
// #2573: stamp the commit this STATE.md was written against, so consumers can
|
|
2137
|
+
// report how far the codebase has moved since. Omitted entirely outside a git
|
|
2138
|
+
// repo — an absent field reads as "unknown", which is the honest answer and
|
|
2139
|
+
// keeps every consumer's tri-state intact (see readStateHeadFreshness).
|
|
2140
|
+
const stateHead = readGitHeadSha(cwd);
|
|
2141
|
+
if (stateHead)
|
|
2142
|
+
fm['state_head'] = stateHead;
|
|
1614
2143
|
const progress = {};
|
|
1615
2144
|
if (totalPhases !== null)
|
|
1616
2145
|
progress['total_phases'] = totalPhases;
|
|
@@ -1626,14 +2155,205 @@ function buildStateFrontmatter(bodyContent, cwd) {
|
|
|
1626
2155
|
fm['progress'] = progress;
|
|
1627
2156
|
return fm;
|
|
1628
2157
|
}
|
|
1629
|
-
|
|
2158
|
+
// ─── state_head commit provenance (#2573) ────────────────────────────────────
|
|
2159
|
+
//
|
|
2160
|
+
// STATE.md records the commit it was written against (`state_head`); consumers
|
|
2161
|
+
// derive how many commits the codebase has moved since. This mirrors the shipped
|
|
2162
|
+
// graphify commit-staleness contract (src/graphify.cts, #3170) rather than
|
|
2163
|
+
// inventing a second vocabulary: `commits_behind` is a count, and `commit_stale`
|
|
2164
|
+
// is TRI-STATE — null means "we don't know" (no git, no stamp, unresolvable
|
|
2165
|
+
// commit), which is deliberately distinct from false ("known fresh").
|
|
2166
|
+
//
|
|
2167
|
+
// IMPORTANT — this is a freshness PROXY, never a drift measurement.
|
|
2168
|
+
// `rev-list state_head..HEAD` counts every commit in between, including ones
|
|
2169
|
+
// that never touched anything STATE.md describes. And because `state_head`
|
|
2170
|
+
// restamps on EVERY state write, a low count means "something wrote STATE
|
|
2171
|
+
// recently", NOT "STATE's content is accurate". Consumers must word it as
|
|
2172
|
+
// approximate and must never gate on it.
|
|
2173
|
+
/** Strict hash fence before any value from disk reaches a git argument. */
|
|
2174
|
+
const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
|
|
2175
|
+
/**
|
|
2176
|
+
* Resolve the project's current HEAD sha, or null when unavailable.
|
|
2177
|
+
* Bounded + non-interactive via execGit (10s timeout, GIT_TERMINAL_PROMPT=0);
|
|
2178
|
+
* a non-repo, missing git, or timeout degrades to null rather than throwing.
|
|
2179
|
+
*/
|
|
2180
|
+
/**
|
|
2181
|
+
* Does the project root carry its own git repository?
|
|
2182
|
+
*
|
|
2183
|
+
* #2573 D5. `git rev-parse HEAD` walks UP from cwd and stops at the FIRST
|
|
2184
|
+
* enclosing `.git`. So the repo that answered is the project's own exactly when
|
|
2185
|
+
* the project root itself carries a `.git` entry — a directory for a normal
|
|
2186
|
+
* clone, a file for a worktree or submodule, both of which `existsSync` accepts.
|
|
2187
|
+
* If it does not, the answer necessarily came from an ancestor repo and the
|
|
2188
|
+
* stamp would assert provenance the project cannot claim.
|
|
2189
|
+
*
|
|
2190
|
+
* Deliberately a filesystem-identity check rather than comparing
|
|
2191
|
+
* `--show-toplevel` against the project root as strings. That comparison is
|
|
2192
|
+
* unreliable across platforms — macOS resolves temp dirs through
|
|
2193
|
+
* `/private/var/…`, Windows adds 8.3 short names and separator/case variance —
|
|
2194
|
+
* and an over-strict compare degrades healthy projects to "unknown", which is
|
|
2195
|
+
* the very failure this check exists to prevent, inverted. No path spelling is
|
|
2196
|
+
* involved here at all.
|
|
2197
|
+
*/
|
|
2198
|
+
function projectOwnsItsRepo(projectRoot) {
|
|
2199
|
+
try {
|
|
2200
|
+
return node_fs_1.default.existsSync(node_path_1.default.join(projectRoot, '.git'));
|
|
2201
|
+
}
|
|
2202
|
+
catch {
|
|
2203
|
+
return false;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
function readGitHeadSha(cwd) {
|
|
2207
|
+
if (!cwd)
|
|
2208
|
+
return null;
|
|
2209
|
+
// #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the nearest
|
|
2210
|
+
// enclosing `.git`, and nothing pins that repo to the project. A GSD project
|
|
2211
|
+
// living inside an unrelated checkout — a dotfiles/notes repo, or the outer
|
|
2212
|
+
// workspace of a `planning.sub_repos` layout where all code commits land in
|
|
2213
|
+
// the sub-repos — would otherwise measure freshness against a repo it has no
|
|
2214
|
+
// relationship to, and report `commit_stale: false` ("known fresh") while
|
|
2215
|
+
// doing it. Unverified provenance must degrade to unknown, never to fresh.
|
|
2216
|
+
//
|
|
2217
|
+
// TWO independent conditions must hold before a stamp is trustworthy, and both
|
|
2218
|
+
// are checked below because either alone is insufficient:
|
|
2219
|
+
// 1. the project root owns a `.git` (else an ancestor repo answered), and
|
|
2220
|
+
// 2. the project is not a `sub_repos` workspace (else the repo that answers
|
|
2221
|
+
// is the outer wrapper, whose HEAD does not move when the code does).
|
|
2222
|
+
// KNOWN LIMITATION, by design: in a `sub_repos` workspace this feature reports
|
|
2223
|
+
// unknown rather than measuring the children. Per-child freshness needs a
|
|
2224
|
+
// defined aggregate across N histories and is out of scope for this increment.
|
|
2225
|
+
//
|
|
2226
|
+
// `--show-toplevel HEAD` answers both in ONE spawn, so pinning costs no extra
|
|
2227
|
+
// subprocess on this path (the caller holds the STATE lock).
|
|
2228
|
+
let projectRoot;
|
|
2229
|
+
try {
|
|
2230
|
+
projectRoot = (0, project_root_cjs_1.findProjectRoot)(cwd);
|
|
2231
|
+
}
|
|
2232
|
+
catch {
|
|
2233
|
+
return null; // cannot prove which repo would answer → unknown
|
|
2234
|
+
}
|
|
2235
|
+
if (!projectOwnsItsRepo(projectRoot))
|
|
2236
|
+
return null;
|
|
2237
|
+
// #2573 D5, sub_repos flavor. Owning a `.git` is necessary but NOT sufficient.
|
|
2238
|
+
// In a `planning.sub_repos` workspace the outer directory can legitimately own
|
|
2239
|
+
// BOTH `.planning/` and its own repo while every code commit lands in a nested
|
|
2240
|
+
// child repo — `docs/CONFIGURATION.md` describes sub_repos as scoping work per
|
|
2241
|
+
// sub-repo "instead of treating the outer repo as a monorepo". The outer HEAD
|
|
2242
|
+
// then never advances, so `merge-base --is-ancestor` passes trivially and
|
|
2243
|
+
// `rev-list` counts 0: the stamp would report `commit_stale: false`, i.e.
|
|
2244
|
+
// "known fresh", while the code it describes has moved arbitrarily far.
|
|
2245
|
+
//
|
|
2246
|
+
// That is a WRONG answer, not a missing one, and it is the same invariant the
|
|
2247
|
+
// ancestor-repo check above exists to protect: a freshness claim the project
|
|
2248
|
+
// cannot substantiate must degrade to unknown, never to fresh. Measuring the
|
|
2249
|
+
// children instead would mean picking one HEAD out of N unrelated histories
|
|
2250
|
+
// (or inventing an aggregate), which is a design question beyond this
|
|
2251
|
+
// increment — so this scopes to the honest tri-state and declines to answer.
|
|
2252
|
+
// Deliberately keyed on the DECLARED config rather than probing the filesystem
|
|
2253
|
+
// for nested `.git` entries: the declaration is what the workspace asserts
|
|
2254
|
+
// about itself, and a probe would spuriously fire on a vendored dependency.
|
|
2255
|
+
try {
|
|
2256
|
+
const subRepos = loadConfig(projectRoot).sub_repos;
|
|
2257
|
+
if (Array.isArray(subRepos) && subRepos.length > 0)
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
catch {
|
|
2261
|
+
return null; // cannot read the layout → cannot claim provenance → unknown
|
|
2262
|
+
}
|
|
2263
|
+
const r = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', 'HEAD'], { cwd });
|
|
2264
|
+
if (r.exitCode !== 0)
|
|
2265
|
+
return null;
|
|
2266
|
+
const sha = r.stdout.trim();
|
|
2267
|
+
return STATE_HEAD_HASH_RE.test(sha) ? sha : null;
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Derive the commit-age freshness signal from a recorded `state_head`.
|
|
2271
|
+
*
|
|
2272
|
+
* Single source of truth for the derivation — `validate.health` (W024) and
|
|
2273
|
+
* smart-entry both consume this rather than re-deriving it, so the tri-state
|
|
2274
|
+
* and the hash fence cannot drift apart between surfaces.
|
|
2275
|
+
*
|
|
2276
|
+
* Never throws: every unresolvable input degrades to nulls.
|
|
2277
|
+
*/
|
|
2278
|
+
function readStateHeadFreshness(cwd, stateHead) {
|
|
2279
|
+
const raw = (typeof stateHead === 'string' ? stateHead : '').trim();
|
|
2280
|
+
const stamp = STATE_HEAD_HASH_RE.test(raw) ? raw : null;
|
|
2281
|
+
const head = readGitHeadSha(cwd);
|
|
2282
|
+
let commitsBehind = null;
|
|
2283
|
+
let commitStale = null;
|
|
2284
|
+
if (stamp && head && cwd) {
|
|
2285
|
+
// The stamp must be an ANCESTOR of HEAD before a distance means anything.
|
|
2286
|
+
// `rev-list --count A..B` exits 0 with "0" when A is not reachable from B —
|
|
2287
|
+
// which is what a `reset --hard` to an earlier commit, a rebase or squash
|
|
2288
|
+
// that drops the stamped commit, or a force-push rewriting history all
|
|
2289
|
+
// produce. Without this guard those cases report `commit_stale: false`,
|
|
2290
|
+
// i.e. "known fresh", for a codebase that was actually rewound past the
|
|
2291
|
+
// stamp — collapsing the exact unknown-vs-fresh distinction this tri-state
|
|
2292
|
+
// exists to preserve. A non-ancestor stamp is UNKNOWN, so it stays null.
|
|
2293
|
+
const ancestry = (0, shell_command_projection_cjs_1.execGit)(['merge-base', '--is-ancestor', stamp, head], { cwd });
|
|
2294
|
+
if (ancestry.exitCode === 0) {
|
|
2295
|
+
const r = (0, shell_command_projection_cjs_1.execGit)(['rev-list', '--count', `${stamp}..${head}`], { cwd });
|
|
2296
|
+
if (r.exitCode === 0) {
|
|
2297
|
+
const n = parseInt(r.stdout.trim(), 10);
|
|
2298
|
+
if (Number.isFinite(n)) {
|
|
2299
|
+
commitsBehind = n;
|
|
2300
|
+
// #2573 D4 — deliberately RAW, not thresholded. `commit_stale` means
|
|
2301
|
+
// exactly what its contract says: the codebase has moved since the
|
|
2302
|
+
// stamp. Applying an advisory threshold here would make the field lie
|
|
2303
|
+
// at n < threshold, and W024 needs the true count to threshold on.
|
|
2304
|
+
// Alarm-fatigue is handled at the ALARMING surface, not the
|
|
2305
|
+
// derivation: W024 (the only user-visible consumer) fires at
|
|
2306
|
+
// STATE_HEAD_ADVISORY_COMMITS, which absorbs the `commit_docs: true`
|
|
2307
|
+
// off-by-one. Smart-entry re-exports the raw tri-state as advisory
|
|
2308
|
+
// JSON and is not consumed by classify().
|
|
2309
|
+
commitStale = n > 0;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
return {
|
|
2315
|
+
state_head: stamp ? stamp.slice(0, 7) : null,
|
|
2316
|
+
current_commit: head ? head.slice(0, 7) : null,
|
|
2317
|
+
commits_behind: commitsBehind,
|
|
2318
|
+
commit_stale: commitStale,
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* #3354: read `progress.total_phases` out of already-extracted STATE.md
|
|
2323
|
+
* frontmatter as a finite number, or null. Feeds buildStateFrontmatter's
|
|
2324
|
+
* milestoned-but-unbounded withhold so the stored total survives the write
|
|
2325
|
+
* instead of being clobbered by the on-disk phase-directory count.
|
|
2326
|
+
*/
|
|
2327
|
+
function readStoredTotalPhases(existingFm) {
|
|
2328
|
+
if (!existingFm || typeof existingFm !== 'object')
|
|
2329
|
+
return null;
|
|
2330
|
+
const progress = existingFm['progress'];
|
|
2331
|
+
if (!progress || typeof progress !== 'object')
|
|
2332
|
+
return null;
|
|
2333
|
+
const raw = progress['total_phases'];
|
|
2334
|
+
if (raw === null || raw === undefined)
|
|
2335
|
+
return null;
|
|
2336
|
+
if (typeof raw === 'string' && raw.trim() === '')
|
|
2337
|
+
return null;
|
|
2338
|
+
const n = Number(raw);
|
|
2339
|
+
return Number.isFinite(n) ? n : null;
|
|
2340
|
+
}
|
|
2341
|
+
function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
|
|
1630
2342
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
|
1631
2343
|
// that the body no longer has (e.g., Status field removed by an agent).
|
|
1632
2344
|
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
|
|
1633
2345
|
// derivable here without widening the signature (#1882).
|
|
1634
2346
|
const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
|
|
1635
2347
|
const body = stripFrontmatter(content);
|
|
1636
|
-
|
|
2348
|
+
// #3017: pass the stored milestone from the existing frontmatter so
|
|
2349
|
+
// buildStateFrontmatter scopes its disk scan to the correct milestone
|
|
2350
|
+
// instead of auto-deriving (and potentially mis-binding).
|
|
2351
|
+
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
2352
|
+
// #3354: also pass the stored total so buildStateFrontmatter's
|
|
2353
|
+
// milestoned-but-unbounded withhold can preserve it across the write
|
|
2354
|
+
// (the derived progress sub-block replaces the stored one wholesale below,
|
|
2355
|
+
// so an omitted key would otherwise DELETE the stored value).
|
|
2356
|
+
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
1637
2357
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
1638
2358
|
// This prevents a missing Status: field in the body from overwriting a
|
|
1639
2359
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -1668,52 +2388,128 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
|
|
|
1668
2388
|
derivedFm['milestone'] = existingFm['milestone'];
|
|
1669
2389
|
}
|
|
1670
2390
|
}
|
|
1671
|
-
//
|
|
1672
|
-
//
|
|
1673
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
2391
|
+
// ADR-3408 §8.5 (D1): the six empty-only "#905" guards that used to live
|
|
2392
|
+
// here UNCONDITIONALLY are deleted for the write-seam pipeline
|
|
2393
|
+
// (`syncAndPreserveStateMd`, consumed by `readModifyWriteStateMd` and by
|
|
2394
|
+
// `cmdPhaseComplete`'s atomic-commit adapter). An empty derived value now
|
|
2395
|
+
// reaches `applyStatePreservation` unmolested, so the table-driven executor
|
|
2396
|
+
// — not a private copy inside this function — decides whether a curated
|
|
2397
|
+
// frontmatter value survives, and reports the decision via
|
|
2398
|
+
// `divergedFields` when it does. That was the actual D1 bug: these guards
|
|
2399
|
+
// ran BEFORE the executor ever saw the value, so a transform that
|
|
2400
|
+
// deliberately emptied a body line (delta CHANGED) lost silently — the
|
|
2401
|
+
// guard restored the stale frontmatter, the executor's own #1230 delta
|
|
2402
|
+
// check then found "already restored, nothing to do", and
|
|
2403
|
+
// `divergedFields` stayed empty even though a curated value had just won
|
|
2404
|
+
// over a genuine derived-empty.
|
|
2405
|
+
//
|
|
2406
|
+
// `writeStateMd`'s two callers — `cmdStateSync` and `/gsd-health --repair`'s
|
|
2407
|
+
// `REGENERATE_STATE` — are §8.3's closed, sanctioned-permanent exception
|
|
2408
|
+
// list: NEITHER ever runs `applyStatePreservation` afterward, because their
|
|
2409
|
+
// whole contract is "re-derive frontmatter FROM the body, body wins" (the
|
|
2410
|
+
// opposite of preservation). For them, these six conditions are the ONLY
|
|
2411
|
+
// mechanism that has ever kept a curated frontmatter value alive when the
|
|
2412
|
+
// body simply carries no annotation for a field at all (most STATE.md
|
|
2413
|
+
// files do not restate every field in body prose on every write) — losing
|
|
2414
|
+
// that would blank `current_phase_name` / `stopped_at` / etc. on every
|
|
2415
|
+
// `state sync`, which is a regression, not this phase's fix: `state sync`'s
|
|
2416
|
+
// output must stay byte-identical (ADR-3408 §8.3 Amendment 2). So the same
|
|
2417
|
+
// six conditions are kept, verbatim, but now gated behind the explicit
|
|
2418
|
+
// `sanctionedPermanentEmptyFallback` parameter — threaded ONLY from
|
|
2419
|
+
// `writeStateMd` — instead of running unconditionally or being duplicated
|
|
2420
|
+
// as a second private copy. This is still ONE enforcement point: the six
|
|
2421
|
+
// conditions exist in exactly one place in the source, selected by caller
|
|
2422
|
+
// identity per the closed §8.3 exception list, never re-derived elsewhere.
|
|
1677
2423
|
//
|
|
1678
|
-
//
|
|
1679
|
-
//
|
|
1680
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
derivedFm['
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
derivedFm['
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
derivedFm['
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
derivedFm['
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
2424
|
+
// The disagreeing case (a present-but-stale body value vs a fresher
|
|
2425
|
+
// frontmatter value, #948/#3374/§8.5) was never handled here even before
|
|
2426
|
+
// this change: it is governed by applyStatePreservation's
|
|
2427
|
+
// preserve-when-unchanged delta, applied post-sync by the shared
|
|
2428
|
+
// applyPostSyncPreservation pass.
|
|
2429
|
+
if (sanctionedPermanentEmptyFallback) {
|
|
2430
|
+
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
|
|
2431
|
+
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
|
2432
|
+
}
|
|
2433
|
+
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
|
|
2434
|
+
derivedFm['paused_at'] = existingFm['paused_at'];
|
|
2435
|
+
}
|
|
2436
|
+
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
|
|
2437
|
+
derivedFm['current_phase'] = existingFm['current_phase'];
|
|
2438
|
+
}
|
|
2439
|
+
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
|
|
2440
|
+
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
|
|
2441
|
+
}
|
|
2442
|
+
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
|
|
2443
|
+
derivedFm['current_plan'] = existingFm['current_plan'];
|
|
2444
|
+
}
|
|
2445
|
+
// progress is a sub-object: fall back to existing only when the
|
|
2446
|
+
// body+disk scan produced NO progress block at all. When
|
|
2447
|
+
// buildStateFrontmatter did derive a progress block (even a lower one),
|
|
2448
|
+
// that derived value wins — the shouldPreserveExistingProgress
|
|
2449
|
+
// cross-milestone logic is applied later in cmdStateJson on the read
|
|
2450
|
+
// path where it is appropriate.
|
|
2451
|
+
if (!derivedFm['progress'] && existingFm['progress']) {
|
|
2452
|
+
derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
|
|
2453
|
+
}
|
|
1708
2454
|
}
|
|
1709
2455
|
// #2202: carry forward any existing frontmatter key that the schema does not
|
|
1710
2456
|
// own, so custom/unknown keys are not silently dropped on every mutating verb.
|
|
1711
2457
|
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
|
|
1712
|
-
//
|
|
2458
|
+
// sanctioned-permanent guards above, when they ran) still win.
|
|
1713
2459
|
for (const key of Object.keys(existingFm)) {
|
|
1714
|
-
if (
|
|
1715
|
-
|
|
1716
|
-
|
|
2460
|
+
if (key in derivedFm || existingFm[key] === undefined)
|
|
2461
|
+
continue;
|
|
2462
|
+
// #2573: a `source: 'free'` field is the writer's word on every write and
|
|
2463
|
+
// carries no preservation (see the FieldSource doc). When buildStateFrontmatter
|
|
2464
|
+
// omits it — `state_head` outside a git repo, per its `if (stateHead)` guard —
|
|
2465
|
+
// carrying the old value forward would re-assert provenance the file no longer
|
|
2466
|
+
// has: a stale state_head would claim STATE.md was written against a commit it
|
|
2467
|
+
// wasn't, contradicting its own ADR-1769 row.
|
|
2468
|
+
//
|
|
2469
|
+
// Narrow the skip to `source: 'free'`, NOT every `derive` row. `last_activity`
|
|
2470
|
+
// ({source:'body'}) and the `progress.*` rows ({source:'disk'}) are also
|
|
2471
|
+
// `derive`, but they are body/disk-sourced and MUST still carry forward when
|
|
2472
|
+
// the writer omits them this pass — dropping `last_activity` here is silent
|
|
2473
|
+
// frontmatter data loss and would defeat #2570's staleness fix downstream.
|
|
2474
|
+
// `last_updated` and `gsd_state_version` are the only other `free` rows and are
|
|
2475
|
+
// both produced unconditionally by buildStateFrontmatter, so this loop never
|
|
2476
|
+
// reaches them; `state_head` is the sole field the skip governs. Consult the
|
|
2477
|
+
// table rather than naming fields, so the policy stays single-sourced.
|
|
2478
|
+
const classification = stateTransitionMod.getFieldClassification(key);
|
|
2479
|
+
if (classification && classification.source === 'free')
|
|
2480
|
+
continue;
|
|
2481
|
+
// ADR-3408 §8.1/§8.5 (D1 follow-on — found by probe, not predicted by the
|
|
2482
|
+
// design): a `preserve-when-unchanged` / `preserve-always` field must be
|
|
2483
|
+
// decided ONLY by `applyStatePreservation` — the single enforcement point
|
|
2484
|
+
// — never by this generic carry-forward, on the write-seam path. Before
|
|
2485
|
+
// the six sanctioned-permanent guards above were gated behind
|
|
2486
|
+
// `sanctionedPermanentEmptyFallback` (D1), this loop's `key in derivedFm`
|
|
2487
|
+
// check was effectively always true for a field the guards had already
|
|
2488
|
+
// restored, so this branch was unreachable for it and the distinction
|
|
2489
|
+
// never mattered. With the guards now OFF on the write-seam path,
|
|
2490
|
+
// `derivedFm` genuinely lacks the key when the body carries no
|
|
2491
|
+
// annotation — and without this skip, this loop silently resurrects the
|
|
2492
|
+
// exact stale value the executor's delta rule (§8.5 Row 2) just decided
|
|
2493
|
+
// to discard, re-introducing the D1 bug through a second, unrelated code
|
|
2494
|
+
// path (confirmed live: an A5-shaped probe restored `current_phase_name`
|
|
2495
|
+
// via THIS loop even with the six guards deleted).
|
|
2496
|
+
//
|
|
2497
|
+
// Gated to the write-seam path ONLY (`!sanctionedPermanentEmptyFallback`)
|
|
2498
|
+
// — `writeStateMd`'s two sanctioned-permanent callers never run
|
|
2499
|
+
// `applyStatePreservation` at all, so unconditionally skipping here would
|
|
2500
|
+
// blank fields this loop has always carried forward for them (e.g.
|
|
2501
|
+
// `last_activity_desc`, which was never one of the six explicit guards
|
|
2502
|
+
// above but relied on THIS loop for its empty-case fallback), breaking
|
|
2503
|
+
// `state sync`'s required byte-identical output for a field D1 never
|
|
2504
|
+
// named. On the write-seam path this executor-only rule genuinely widens
|
|
2505
|
+
// beyond the original six fields (e.g. also covers `last_activity_desc`)
|
|
2506
|
+
// — a deliberate, in-scope consequence of "one enforcement point", not a
|
|
2507
|
+
// separate defect.
|
|
2508
|
+
if (!sanctionedPermanentEmptyFallback &&
|
|
2509
|
+
classification &&
|
|
2510
|
+
(classification.preservation === 'preserve-when-unchanged' || classification.preservation === 'preserve-always'))
|
|
2511
|
+
continue;
|
|
2512
|
+
derivedFm[key] = existingFm[key];
|
|
1717
2513
|
}
|
|
1718
2514
|
// #2567: guard the information-losing direction — a stale archive
|
|
1719
2515
|
// "Last activity:" line must not overwrite a newer frontmatter value.
|
|
@@ -1732,6 +2528,11 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
|
|
|
1732
2528
|
}
|
|
1733
2529
|
}
|
|
1734
2530
|
}
|
|
2531
|
+
// #3257: propagate full-line frontmatter comments from the extracted source onto the
|
|
2532
|
+
// rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
|
|
2533
|
+
// skip the Symbol-keyed channel, so without this the comments would be lost here even
|
|
2534
|
+
// though parseYamlRegion/reconstructFrontmatter preserve them in isolation).
|
|
2535
|
+
propagateCommentChannel(existingFm, derivedFm);
|
|
1735
2536
|
const yamlStr = reconstructFrontmatter(derivedFm);
|
|
1736
2537
|
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
1737
2538
|
}
|
|
@@ -1849,17 +2650,22 @@ function acquireStateLock(statePath, clock) {
|
|
|
1849
2650
|
if (err.code !== 'EEXIST')
|
|
1850
2651
|
throw err; // propagate — silent bypass causes lost updates
|
|
1851
2652
|
// Liveness-gated steal (audit M1) + steal-safety (PR #1532 review). The steal
|
|
1852
|
-
// decision is
|
|
2653
|
+
// decision is four-way on the lock body (#3057 B2 added the fourth):
|
|
1853
2654
|
// - VERIFIED-LIVE holder (parseable pid that signals alive): NEVER stolen until
|
|
1854
2655
|
// its age crosses the absolute deadman ceiling (the pid-reuse backstop) —
|
|
1855
2656
|
// nuking a slow-but-live writer's lock causes lost updates (#3711 / #500/#905/
|
|
1856
2657
|
// #1230 family).
|
|
1857
2658
|
// - COMPLETE DEAD pid (parseable pid, not alive): stolen PROMPTLY regardless of
|
|
1858
2659
|
// age — a crashed holder left a full body.
|
|
1859
|
-
// -
|
|
1860
|
-
//
|
|
1861
|
-
//
|
|
1862
|
-
//
|
|
2660
|
+
// - UNREADABLE body (I/O fault reading the file): NOT the same as empty — we
|
|
2661
|
+
// have no evidence this is a fresh create window, only that we could not read
|
|
2662
|
+
// it. Held to the SAME conservative ceiling as a verified-live holder rather
|
|
2663
|
+
// than the short fresh-create floor, so a transient read fault can never rob
|
|
2664
|
+
// an active holder the way stealing at 1s would.
|
|
2665
|
+
// - EMPTY / unparseable body (body WAS read, and holds no valid pid): liveness is
|
|
2666
|
+
// unknowable. While FRESH (age <= freshCreateFloorMs) it is a lock still
|
|
2667
|
+
// mid-creation (O_EXCL done, pid not yet written) and is NOT stolen (window a);
|
|
2668
|
+
// only once aged past the floor is it a genuine orphan and stealable.
|
|
1863
2669
|
// The steal itself is an ATOMIC rename-then-recreate (only one racer can rename the
|
|
1864
2670
|
// inode) guarded by an identity re-confirm, so a racer that recreates a fresh lock
|
|
1865
2671
|
// in the decision→steal gap never has its replacement deleted (window b). Mirrors
|
|
@@ -1867,7 +2673,8 @@ function acquireStateLock(statePath, clock) {
|
|
|
1867
2673
|
try {
|
|
1868
2674
|
const stat = node_fs_1.default.statSync(lockPath);
|
|
1869
2675
|
const ageMs = clock.now() - stat.mtimeMs;
|
|
1870
|
-
const
|
|
2676
|
+
const bodyStatus = _stateLockBodyStatus(lockPath);
|
|
2677
|
+
const bodyPid = bodyStatus.kind === 'pid' ? bodyStatus.pid : null;
|
|
1871
2678
|
const holderLive = bodyPid !== null && _stateLockIsPidAlive(bodyPid);
|
|
1872
2679
|
let steal;
|
|
1873
2680
|
if (holderLive) {
|
|
@@ -1876,6 +2683,9 @@ function acquireStateLock(statePath, clock) {
|
|
|
1876
2683
|
else if (bodyPid !== null) {
|
|
1877
2684
|
steal = true; // complete dead pid → prompt steal
|
|
1878
2685
|
}
|
|
2686
|
+
else if (bodyStatus.kind === 'unreadable') {
|
|
2687
|
+
steal = ageMs > deadmanCeilingMs; // I/O fault ≠ known-fresh — do not grant the short floor
|
|
2688
|
+
}
|
|
1879
2689
|
else {
|
|
1880
2690
|
steal = ageMs > freshCreateFloorMs; // empty/garbage → protect the create window
|
|
1881
2691
|
}
|
|
@@ -1993,13 +2803,284 @@ function writeStateMd(statePath, content, cwd, clock) {
|
|
|
1993
2803
|
// files that buildStateFrontmatter must see (#1967).
|
|
1994
2804
|
if (cwd)
|
|
1995
2805
|
_diskScanCache.delete(cwd);
|
|
1996
|
-
|
|
2806
|
+
// ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
|
|
2807
|
+
// sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
|
|
2808
|
+
// pass `sanctionedPermanentEmptyFallback: true` so their long-standing
|
|
2809
|
+
// empty-field fallback behavior stays byte-identical (see
|
|
2810
|
+
// `syncStateFrontmatter`'s docstring above the guard block).
|
|
2811
|
+
const synced = syncStateFrontmatter(content, cwd, undefined, true);
|
|
1997
2812
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
1998
2813
|
}
|
|
1999
2814
|
finally {
|
|
2000
2815
|
releaseStateLock(lockPath);
|
|
2001
2816
|
}
|
|
2002
2817
|
}
|
|
2818
|
+
/**
|
|
2819
|
+
* #3374: the shared post-sync preservation pass — the pre/post body-source
|
|
2820
|
+
* snapshot + table-driven `applyStatePreservation` + #2736 authoritative
|
|
2821
|
+
* re-assert sequence. Extracted from readModifyWriteStateMd so
|
|
2822
|
+
* `cmdPhaseComplete`'s atomic-commit adapter (phase.cts) — which syncs
|
|
2823
|
+
* STATE.md directly because it is committed atomically with
|
|
2824
|
+
* ROADMAP/REQUIREMENTS and so cannot go through the RMW wrapper — applies the
|
|
2825
|
+
* identical policy instead of a second, weaker encoding. Previously the
|
|
2826
|
+
* adapter had no preservation at all, letting a stale body `Stopped at:` line
|
|
2827
|
+
* silently clobber a fresher frontmatter `stopped_at` on every phase
|
|
2828
|
+
* completion (#3374 Variant A).
|
|
2829
|
+
*
|
|
2830
|
+
* NOT applied on the writeStateMd path: `state sync`'s contract is the
|
|
2831
|
+
* opposite by design (#905 — "body annotation beats existing frontmatter when
|
|
2832
|
+
* both are present": sync exists to re-derive frontmatter from the body), so a
|
|
2833
|
+
* blanket preservation pass there re-locks stale frontmatter. The
|
|
2834
|
+
* milestone-complete equivalent of the #3374 exposure is tracked as a
|
|
2835
|
+
* follow-up (see PR #3491 / the closed PR #3442 review's MAJOR finding).
|
|
2836
|
+
*
|
|
2837
|
+
* `originalContent` is the pre-write on-disk content (drives the #1230
|
|
2838
|
+
* pre-snapshots), `transformedContent` is the post-transform content (the
|
|
2839
|
+
* sync only rewrites the frontmatter block, so its body IS the post-write
|
|
2840
|
+
* body), and `syncedContent` is what `syncStateFrontmatter` produced.
|
|
2841
|
+
*/
|
|
2842
|
+
/**
|
|
2843
|
+
* #3471 Fix: `StatePreservationOptions` is silently mis-consumable by any
|
|
2844
|
+
* non-TypeScript caller — `tsc` only type-checks src/, so a plain-.cjs test
|
|
2845
|
+
* (or any future JS caller) can pass a boolean where this options object
|
|
2846
|
+
* goes and both functions below would previously proceed with `resync`,
|
|
2847
|
+
* `authoritativeFm`, `deriveProgressKeys`, and `divergedFields` all
|
|
2848
|
+
* `undefined`, degrading to a well-formed-looking but silently-empty
|
|
2849
|
+
* `divergedFields: []` — exactly the "stale but present" failure shape
|
|
2850
|
+
* ADR-3408 exists to remove. This is a contract assertion (caller-shape
|
|
2851
|
+
* only), not field-level validation — mirrors `throwUnwiredRow`'s
|
|
2852
|
+
* structured-error shape in src/state-transition.cts.
|
|
2853
|
+
*/
|
|
2854
|
+
function assertStatePreservationOptions(options, caller) {
|
|
2855
|
+
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
|
|
2856
|
+
const err = new Error(`${caller}: options argument must be a StatePreservationOptions object, got ${typeof options === 'object' ? 'array/null' : typeof options}. ` +
|
|
2857
|
+
'This function takes a single options object as its final ' +
|
|
2858
|
+
'parameter, not positional resync/authoritativeFm/deriveProgressKeys/divergedFields arguments (#3471).');
|
|
2859
|
+
err.code = 'STATE_PRESERVATION_OPTIONS_INVALID';
|
|
2860
|
+
err.receivedType = Array.isArray(options) ? 'array' : typeof options;
|
|
2861
|
+
throw err;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
|
|
2865
|
+
assertStatePreservationOptions(options, 'applyPostSyncPreservation');
|
|
2866
|
+
const { resync, authoritativeFm, deriveProgressKeys, divergedFields } = options;
|
|
2867
|
+
// Snapshot the existing progress block BEFORE the transform so we can
|
|
2868
|
+
// restore it when resync is false.
|
|
2869
|
+
const preFm = resync ? null : extractFrontmatter(originalContent, statePath);
|
|
2870
|
+
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
2871
|
+
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
2872
|
+
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
2873
|
+
// when the body's source field was NOT changed by the transform, the
|
|
2874
|
+
// existing frontmatter value (e.g. a hand-set 'completed') must win over
|
|
2875
|
+
// the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
|
|
2876
|
+
// Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
|
|
2877
|
+
// above (null when resync:true) — these are independent snapshots.
|
|
2878
|
+
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
2879
|
+
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
2880
|
+
const preBody = stripFrontmatter(originalContent);
|
|
2881
|
+
const preFmSnapshot = extractFrontmatter(originalContent, statePath);
|
|
2882
|
+
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
2883
|
+
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
2884
|
+
// mirroring buildStateFrontmatter's sessionBodyScope logic.
|
|
2885
|
+
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
2886
|
+
// Archive prose) must not interfere with the delta comparison.
|
|
2887
|
+
const preSessionMatch = matchSessionSection(preBody);
|
|
2888
|
+
const preSessionScope = preSessionMatch ?? preBody;
|
|
2889
|
+
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
2890
|
+
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
2891
|
+
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
2892
|
+
// this write does NOT change that line, the curated frontmatter value must
|
|
2893
|
+
// win over syncStateFrontmatter's body re-derivation (which can harvest a
|
|
2894
|
+
// wrong parenthetical aside — #1695). Gated by the field-classification
|
|
2895
|
+
// table's preserve-always row so the rule lives in one place.
|
|
2896
|
+
const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
|
|
2897
|
+
// #3258: snapshot the body sources for the additional preserve-when-unchanged
|
|
2898
|
+
// rows applyStatePreservation now honors (last_activity_desc, paused_at,
|
|
2899
|
+
// current_phase, current_plan). Each mirrors buildStateFrontmatter's
|
|
2900
|
+
// derivation so the #1230 delta ("did THIS write change the source?") is
|
|
2901
|
+
// accurate: current_phase combines `Current Phase` with the prose `Phase:`
|
|
2902
|
+
// fallback (parseProsePhaseField, scoped to ## Current Position); paused_at
|
|
2903
|
+
// is session-scoped (mirrors stopped_at); last_activity_desc combines the
|
|
2904
|
+
// `Last Activity Description` field with the prose desc fallback.
|
|
2905
|
+
const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
|
|
2906
|
+
const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
|
|
2907
|
+
const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
|
|
2908
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase')).phase;
|
|
2909
|
+
const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
|
|
2910
|
+
const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
|
|
2911
|
+
?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
|
|
2912
|
+
const preBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity Description')
|
|
2913
|
+
?? parseProseLastActivityField(preBodyLastActivityRaw).description;
|
|
2914
|
+
// Post-transform body source fields used for the delta comparison (#1230).
|
|
2915
|
+
// Use `transformedContent` (not `syncedContent`): syncStateFrontmatter only
|
|
2916
|
+
// rewrites the frontmatter block, so the body is identical in both — and we
|
|
2917
|
+
// need the body the transform produced. Strip frontmatter so the YAML
|
|
2918
|
+
// status key cannot shadow the body field we are tracking.
|
|
2919
|
+
const postBody = stripFrontmatter(transformedContent);
|
|
2920
|
+
const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
|
|
2921
|
+
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
2922
|
+
// consistent with the pre-transform snapshot above and buildStateFrontmatter.
|
|
2923
|
+
const postSessionMatch = matchSessionSection(postBody);
|
|
2924
|
+
const postSessionScope = postSessionMatch ?? postBody;
|
|
2925
|
+
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
2926
|
+
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
2927
|
+
// current_phase_name delta comparison.
|
|
2928
|
+
const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
|
|
2929
|
+
// #3258: post-transform body sources for the preserve-when-unchanged rows
|
|
2930
|
+
// added in #3258 (mirrors the pre-transform block above).
|
|
2931
|
+
const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
|
|
2932
|
+
const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
|
|
2933
|
+
const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
|
|
2934
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase')).phase;
|
|
2935
|
+
const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
|
|
2936
|
+
const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
|
|
2937
|
+
?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
|
|
2938
|
+
const postBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity Description')
|
|
2939
|
+
?? parseProseLastActivityField(postBodyLastActivityRaw).description;
|
|
2940
|
+
// #3468: single channel for every preserve-when-unchanged row. Before this
|
|
2941
|
+
// change, seven body-source pre/post pairs travelled in two different
|
|
2942
|
+
// shapes — this map for four fields, six dedicated parameters
|
|
2943
|
+
// (preBodyStatus/postBodyStatus, preBodyStoppedAt/postBodyStoppedAt,
|
|
2944
|
+
// preBodyPhaseSource/postBodyPhaseSource) for the other three — same data,
|
|
2945
|
+
// same purpose, which is exactly why applyStatePreservation needed a
|
|
2946
|
+
// hand-written branch per field instead of one loop over the table. Every
|
|
2947
|
+
// row FIELD_CLASSIFICATION declares preserve-when-unchanged MUST appear
|
|
2948
|
+
// here — an omission now throws (STATE_PRESERVATION_UNWIRED_ROW, ADR-3408
|
|
2949
|
+
// §8.2) at the first write rather than becoming a quiet preservation bug.
|
|
2950
|
+
// Note current_phase_name's source is the body `Phase:` line, deliberately
|
|
2951
|
+
// a DIFFERENT source from current_phase's: the key names the field the
|
|
2952
|
+
// policy GUARDS, not the body field it reads.
|
|
2953
|
+
const bodyDeltas = {
|
|
2954
|
+
last_activity_desc: { pre: preBodyLastActivityDesc, post: postBodyLastActivityDesc },
|
|
2955
|
+
paused_at: { pre: preBodyPausedAt, post: postBodyPausedAt },
|
|
2956
|
+
current_phase: { pre: preBodyCurrentPhase, post: postBodyCurrentPhase },
|
|
2957
|
+
current_plan: { pre: preBodyCurrentPlan, post: postBodyCurrentPlan },
|
|
2958
|
+
status: { pre: preBodyStatus, post: postBodyStatus },
|
|
2959
|
+
stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
|
|
2960
|
+
current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
|
|
2961
|
+
};
|
|
2962
|
+
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
2963
|
+
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
2964
|
+
// in the STATE.md Transition Module. progress / status / stopped_at /
|
|
2965
|
+
// current_phase_name are all governed by their FIELD_CLASSIFICATION row —
|
|
2966
|
+
// one policy source, not three drifting encodings. #3258 extends the same
|
|
2967
|
+
// pass to last_activity_desc / paused_at / current_phase / current_plan
|
|
2968
|
+
// (preserve-when-unchanged) and milestone / milestone_name (preserve-if-
|
|
2969
|
+
// placeholder). Behavior-identical to the pre-#1796 inline block for the
|
|
2970
|
+
// original four fields; this is the absorption ADR-1769 / CONTEXT.md
|
|
2971
|
+
// already claimed shipped.
|
|
2972
|
+
const postFm = extractFrontmatter(syncedContent, statePath);
|
|
2973
|
+
// #3469 (ADR-3408 §8.5): snapshot the freshly-synced (pre-preservation)
|
|
2974
|
+
// frontmatter so a caller that wants visibility into "did preservation
|
|
2975
|
+
// restore a curated value over a disagreeing derived one" can diff against
|
|
2976
|
+
// it via the optional `divergedFields` out-param below. Additive only:
|
|
2977
|
+
// callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
|
|
2978
|
+
// nothing extra and see no change to `synced`/the returned content.
|
|
2979
|
+
const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
|
|
2980
|
+
const preservation = applyStatePreservation({
|
|
2981
|
+
preFm, postFm, preFmSnapshot, resync,
|
|
2982
|
+
deriveProgressKeys: deriveProgressKeys === true,
|
|
2983
|
+
bodyDeltas,
|
|
2984
|
+
});
|
|
2985
|
+
if (divergedFields && preservationInputSnapshot) {
|
|
2986
|
+
// §8.5's "liberal but visible": every field whose value actually
|
|
2987
|
+
// differs before vs after `applyStatePreservation` is a field where the
|
|
2988
|
+
// curated (frontmatter) value won over a disagreeing freshly-derived
|
|
2989
|
+
// one — regardless of which policy executor fired. Diffing the object
|
|
2990
|
+
// (rather than special-casing which executor mutated it) is intentional:
|
|
2991
|
+
// it stays correct if a future FIELD_CLASSIFICATION row adds a new
|
|
2992
|
+
// preservation policy without this function needing to know about it.
|
|
2993
|
+
for (const key of Object.keys(preservation.postFm)) {
|
|
2994
|
+
const before = preservationInputSnapshot[key];
|
|
2995
|
+
const after = preservation.postFm[key];
|
|
2996
|
+
const changed = (typeof before === 'object' || typeof after === 'object')
|
|
2997
|
+
? JSON.stringify(before) !== JSON.stringify(after)
|
|
2998
|
+
: before !== after;
|
|
2999
|
+
if (changed)
|
|
3000
|
+
divergedFields.push(key);
|
|
3001
|
+
}
|
|
3002
|
+
// ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
|
|
3003
|
+
// deleted rather than merely relocated): the loop above can only see a
|
|
3004
|
+
// field that `applyStatePreservation` itself RESTORED — it diffs
|
|
3005
|
+
// `postFm` before vs after the executor ran, and `preserve-when-unchanged`
|
|
3006
|
+
// never adds an absent key back when the body source changed this write
|
|
3007
|
+
// (the delta rule correctly lets the empty derived value win, so `postFm`
|
|
3008
|
+
// never gains the key at all). That means a curated value can vanish —
|
|
3009
|
+
// deliberately, per policy — with NOTHING in the loop above to report it.
|
|
3010
|
+
// "Liberal but visible" requires the discard itself to be named, not just
|
|
3011
|
+
// a restore. Scoped to exactly the fields `bodyDeltas` tracks
|
|
3012
|
+
// (preserve-when-unchanged rows only — `preserve-always`/`progress` and
|
|
3013
|
+
// `preserve-if-placeholder`/`milestone*` are unaffected by the delta rule
|
|
3014
|
+
// and already fully covered by the restore-diff loop above).
|
|
3015
|
+
for (const [field, delta] of Object.entries(bodyDeltas)) {
|
|
3016
|
+
if (divergedFields.includes(field))
|
|
3017
|
+
continue; // already reported as a restore above
|
|
3018
|
+
const before = preFmSnapshot[field];
|
|
3019
|
+
const beforeIsReal = typeof before === 'string' && before.trim().length > 0;
|
|
3020
|
+
if (!beforeIsReal)
|
|
3021
|
+
continue; // nothing curated existed to discard
|
|
3022
|
+
if (delta.pre === delta.post)
|
|
3023
|
+
continue; // body source unchanged — governed by the restore branch, not the discard rule
|
|
3024
|
+
const after = preservation.postFm[field];
|
|
3025
|
+
const afterIsEmpty = after === undefined || after === null
|
|
3026
|
+
|| (typeof after === 'string' && after.trim().length === 0);
|
|
3027
|
+
if (afterIsEmpty)
|
|
3028
|
+
divergedFields.push(field);
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
|
|
3032
|
+
// layouts with no body `Phase:` line, both phase-source snapshots are null
|
|
3033
|
+
// (equal), so the #1695 restore fires and would put the stale pre-transition
|
|
3034
|
+
// name back over the authoritative one. Intent beats both the prose
|
|
3035
|
+
// re-derivation and the curated restore — the transition just resolved it.
|
|
3036
|
+
let authoritativeReasserted = false;
|
|
3037
|
+
if (authoritativeFm) {
|
|
3038
|
+
for (const [key, value] of Object.entries(authoritativeFm)) {
|
|
3039
|
+
if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
|
|
3040
|
+
preservation.postFm[key] = value;
|
|
3041
|
+
authoritativeReasserted = true;
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
if (preservation.mutated || authoritativeReasserted) {
|
|
3046
|
+
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
3047
|
+
const body = stripFrontmatter(syncedContent);
|
|
3048
|
+
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
3049
|
+
}
|
|
3050
|
+
return syncedContent;
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
|
|
3054
|
+
* `applyPostSyncPreservation`, as a single named `content -> content`
|
|
3055
|
+
* function. Every STATE.md write that (a) is not one of the two sanctioned-
|
|
3056
|
+
* permanent exceptions (`cmdStateSync`, `REGENERATE_STATE` — §8.3's closed
|
|
3057
|
+
* exception list, ADR Amendment 2) and (b) needs a non-standard I/O envelope
|
|
3058
|
+
* calls THIS — never `syncStateFrontmatter` + `applyPostSyncPreservation`
|
|
3059
|
+
* assembled locally. §8.3: "Assembling the stages at a call site is a
|
|
3060
|
+
* re-derivation even when every step calls the owner." Phase 2 (#3469) found
|
|
3061
|
+
* exactly that shape live in `cmdPhaseComplete`'s atomic-commit adapter
|
|
3062
|
+
* (phase.cts) — every step called an owner, so the drift guard and an
|
|
3063
|
+
* owner-level test both stayed green while the composition itself was free
|
|
3064
|
+
* to diverge from `readModifyWriteStateMd`'s.
|
|
3065
|
+
*
|
|
3066
|
+
* Both current non-RMW callers of the pair — `readModifyWriteStateMd` and
|
|
3067
|
+
* `cmdPhaseComplete`'s atomic 3-file commit adapter — now call this instead
|
|
3068
|
+
* of assembling the two stages themselves. `cmdMilestoneComplete` (the
|
|
3069
|
+
* #3374-shaped exposure `applyPostSyncPreservation`'s own docstring flagged
|
|
3070
|
+
* as a follow-up) is the third.
|
|
3071
|
+
*
|
|
3072
|
+
* Returns CONTENT ONLY — a caller that needs its own I/O envelope (a lock,
|
|
3073
|
+
* an atomic multi-file commit) supplies it around this call; this function
|
|
3074
|
+
* never takes over the write.
|
|
3075
|
+
*
|
|
3076
|
+
* `divergedFields` is passed straight through to `applyPostSyncPreservation`
|
|
3077
|
+
* — see its own docstring.
|
|
3078
|
+
*/
|
|
3079
|
+
function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
|
|
3080
|
+
assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
|
|
3081
|
+
const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
|
|
3082
|
+
return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options);
|
|
3083
|
+
}
|
|
2003
3084
|
/**
|
|
2004
3085
|
* Atomic read-modify-write for STATE.md.
|
|
2005
3086
|
* Holds the lock across the entire read -> transform -> write cycle,
|
|
@@ -2025,36 +3106,6 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
2025
3106
|
const lockPath = acquireStateLock(statePath, clock);
|
|
2026
3107
|
try {
|
|
2027
3108
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
2028
|
-
// Snapshot the existing progress block BEFORE the transform so we can
|
|
2029
|
-
// restore it when resync is false.
|
|
2030
|
-
const preFm = resync ? null : extractFrontmatter(content, statePath);
|
|
2031
|
-
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
2032
|
-
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
2033
|
-
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
2034
|
-
// when the body's source field was NOT changed by the transform, the
|
|
2035
|
-
// existing frontmatter value (e.g. a hand-set 'completed') must win over
|
|
2036
|
-
// the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
|
|
2037
|
-
// Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
|
|
2038
|
-
// above (null when resync:true) — these are independent snapshots.
|
|
2039
|
-
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
2040
|
-
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
2041
|
-
const preBody = stripFrontmatter(content);
|
|
2042
|
-
const preFmSnapshot = extractFrontmatter(content, statePath);
|
|
2043
|
-
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
2044
|
-
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
2045
|
-
// mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
|
|
2046
|
-
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
2047
|
-
// Archive prose) must not interfere with the delta comparison.
|
|
2048
|
-
const preSessionMatch = matchSessionSection(preBody);
|
|
2049
|
-
const preSessionScope = preSessionMatch ?? preBody;
|
|
2050
|
-
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
2051
|
-
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
2052
|
-
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
2053
|
-
// this write does NOT change that line, the curated frontmatter value must
|
|
2054
|
-
// win over syncStateFrontmatter's body re-derivation (which can harvest a
|
|
2055
|
-
// wrong parenthetical aside — #1695). Gated by the field-classification
|
|
2056
|
-
// table's preserve-always row so the rule lives in one place.
|
|
2057
|
-
const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
|
|
2058
3109
|
const modified = transformFn(content);
|
|
2059
3110
|
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
|
2060
3111
|
// the file. An unconditional write would bump `last_updated`, reset
|
|
@@ -2064,62 +3115,182 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
2064
3115
|
// content already returns the mutated string, and callers that detect a
|
|
2065
3116
|
// no-op explicitly return the original content unchanged.
|
|
2066
3117
|
if (modified === content) {
|
|
2067
|
-
return;
|
|
3118
|
+
return false;
|
|
2068
3119
|
}
|
|
2069
|
-
|
|
2070
|
-
//
|
|
2071
|
-
//
|
|
2072
|
-
//
|
|
2073
|
-
|
|
2074
|
-
const
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const postSessionMatch = matchSessionSection(postBody);
|
|
2078
|
-
const postSessionScope = postSessionMatch ?? postBody;
|
|
2079
|
-
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
2080
|
-
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
2081
|
-
// current_phase_name delta comparison.
|
|
2082
|
-
const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
|
|
2083
|
-
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
2084
|
-
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
2085
|
-
// in the STATE.md Transition Module. progress / status / stopped_at /
|
|
2086
|
-
// current_phase_name are all governed by their FIELD_CLASSIFICATION row —
|
|
2087
|
-
// one policy source, not three drifting encodings. Behavior-identical to
|
|
2088
|
-
// the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
|
|
2089
|
-
// already claimed shipped.
|
|
2090
|
-
const postFm = extractFrontmatter(synced, statePath);
|
|
2091
|
-
const preservation = applyStatePreservation({
|
|
2092
|
-
preFm, postFm, preFmSnapshot, resync,
|
|
3120
|
+
// #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
|
|
3121
|
+
// owned composition (`syncAndPreserveStateMd`), not assembled here — this
|
|
3122
|
+
// call site and `cmdPhaseComplete`'s atomic-commit adapter both route
|
|
3123
|
+
// through the same function so the composition cannot diverge between
|
|
3124
|
+
// the two.
|
|
3125
|
+
const synced = syncAndPreserveStateMd(content, modified, statePath, cwd, {
|
|
3126
|
+
resync,
|
|
3127
|
+
authoritativeFm: options?.authoritativeFm,
|
|
2093
3128
|
deriveProgressKeys: options?.deriveProgressKeys === true,
|
|
2094
|
-
|
|
2095
|
-
preBodyStoppedAt, postBodyStoppedAt,
|
|
2096
|
-
preBodyPhaseSource, postBodyPhaseSource,
|
|
3129
|
+
divergedFields: options?.divergedFields,
|
|
2097
3130
|
});
|
|
2098
|
-
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
|
|
2099
|
-
// layouts with no body `Phase:` line, both phase-source snapshots are null
|
|
2100
|
-
// (equal), so the #1695 restore fires and would put the stale pre-transition
|
|
2101
|
-
// name back over the authoritative one. Intent beats both the prose
|
|
2102
|
-
// re-derivation and the curated restore — the transition just resolved it.
|
|
2103
|
-
let authoritativeReasserted = false;
|
|
2104
|
-
if (options?.authoritativeFm) {
|
|
2105
|
-
for (const [key, value] of Object.entries(options.authoritativeFm)) {
|
|
2106
|
-
if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
|
|
2107
|
-
preservation.postFm[key] = value;
|
|
2108
|
-
authoritativeReasserted = true;
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
if (preservation.mutated || authoritativeReasserted) {
|
|
2113
|
-
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
2114
|
-
const body = stripFrontmatter(synced);
|
|
2115
|
-
synced = `---\n${yamlStr}\n---\n\n${body}`;
|
|
2116
|
-
}
|
|
2117
3131
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
3132
|
+
return true;
|
|
2118
3133
|
}
|
|
2119
3134
|
finally {
|
|
2120
3135
|
releaseStateLock(lockPath);
|
|
2121
3136
|
}
|
|
2122
3137
|
}
|
|
3138
|
+
/**
|
|
3139
|
+
* ADR-3408 §8.4/§8.5 (D4): frontmatter field name → the body Title-Case
|
|
3140
|
+
* label the `updated` arrays below use. Every `preserve-when-unchanged` row
|
|
3141
|
+
* in `FIELD_CLASSIFICATION` MUST have an entry here (pinned by a parity test,
|
|
3142
|
+
* #3471 review) — `reconcileReportedFields` consults this so a preservation
|
|
3143
|
+
* event on `current_phase_name` folds into a report that otherwise only ever
|
|
3144
|
+
* speaks in body labels like `Current Phase Name` (#3345's direction). A
|
|
3145
|
+
* `preserve-when-unchanged` field missing here is a table drift bug and
|
|
3146
|
+
* `bodyLabelFor` throws rather than silently degrading to the raw
|
|
3147
|
+
* snake_case key (#3471 review — this is a second hand-maintained table
|
|
3148
|
+
* parallel to `FIELD_CLASSIFICATION`, so an unwired row must fail as loudly
|
|
3149
|
+
* as `throwUnwiredRow` in `state-transition.cts` does for the same shape of
|
|
3150
|
+
* omission). `preserve-always`/`preserve-if-placeholder` fields (`progress`,
|
|
3151
|
+
* `milestone`, `milestone_name`) are deliberately absent — `divergedFields`
|
|
3152
|
+
* (ADR-3408 §8.5's out-param) is NOT scoped to `preserve-when-unchanged`
|
|
3153
|
+
* rows alone (see `applyPostSyncPreservation`'s "regardless of which policy
|
|
3154
|
+
* executor fired" diff), so those fields legitimately reach the lookup with
|
|
3155
|
+
* no body-line label to report — `progress` is a structured sub-object and
|
|
3156
|
+
* `milestone`/`milestone_name` version/name pairs, neither ever rendered as
|
|
3157
|
+
* a body prose line — and `bodyLabelFor` falls through to the raw key for
|
|
3158
|
+
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
|
|
3159
|
+
* `divergedFields` reporting bare `'progress'`).
|
|
3160
|
+
*/
|
|
3161
|
+
const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
|
|
3162
|
+
current_phase: 'Current Phase',
|
|
3163
|
+
current_phase_name: 'Current Phase Name',
|
|
3164
|
+
current_plan: 'Current Plan',
|
|
3165
|
+
stopped_at: 'Stopped At',
|
|
3166
|
+
paused_at: 'Paused At',
|
|
3167
|
+
status: 'Status',
|
|
3168
|
+
last_activity_desc: 'Last Activity Description',
|
|
3169
|
+
});
|
|
3170
|
+
/**
|
|
3171
|
+
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
|
|
3172
|
+
* entry. Throws for a `preserve-when-unchanged` field with no
|
|
3173
|
+
* `FRONTMATTER_KEY_TO_BODY_LABEL` row — that combination can only happen if
|
|
3174
|
+
* a future row is added to `FIELD_CLASSIFICATION` without a matching label,
|
|
3175
|
+
* an internal table-drift bug, never a user-document defect (mirrors
|
|
3176
|
+
* `throwUnwiredRow`'s shape in `state-transition.cts`: an `Error` carrying
|
|
3177
|
+
* `code` and `field` own-properties). Falls through to the raw field name
|
|
3178
|
+
* for every other policy (`preserve-always`, `preserve-if-placeholder`) —
|
|
3179
|
+
* those fields were never claimed to have a body-line label and reaching
|
|
3180
|
+
* this lookup with one of them is the documented, tested, working case
|
|
3181
|
+
* (e.g. `progress`), not a silent degrade.
|
|
3182
|
+
*/
|
|
3183
|
+
function bodyLabelFor(field) {
|
|
3184
|
+
const label = FRONTMATTER_KEY_TO_BODY_LABEL[field];
|
|
3185
|
+
if (label !== undefined)
|
|
3186
|
+
return label;
|
|
3187
|
+
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3188
|
+
if (cls && cls.preservation === 'preserve-when-unchanged') {
|
|
3189
|
+
const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
|
|
3190
|
+
'FRONTMATTER_KEY_TO_BODY_LABEL entry. This is an internal invariant violation (ADR-3408 ' +
|
|
3191
|
+
'§8.4/D4) — add a label for this field to FRONTMATTER_KEY_TO_BODY_LABEL.');
|
|
3192
|
+
err.code = 'STATE_BODY_LABEL_UNWIRED_ROW';
|
|
3193
|
+
err.field = field;
|
|
3194
|
+
throw err;
|
|
3195
|
+
}
|
|
3196
|
+
return field;
|
|
3197
|
+
}
|
|
3198
|
+
/**
|
|
3199
|
+
* ADR-3408 §8.4 (D4): shared persisted-bytes reconciliation, generalized
|
|
3200
|
+
* from fix(#3351)'s `cmdStatePatch`-only version so every RMW-based command
|
|
3201
|
+
* that reports a per-field `updated` array shares ONE comparison instead of
|
|
3202
|
+
* re-deriving it per call site — duplicated policy is exactly what this
|
|
3203
|
+
* epic exists to remove.
|
|
3204
|
+
*
|
|
3205
|
+
* Closes BOTH directions:
|
|
3206
|
+
* - **#3351's** (reported-but-discarded): a field the transform's own
|
|
3207
|
+
* return value held a value for, that sync/preservation then discarded
|
|
3208
|
+
* or overwrote before the file was saved, must NOT be reported.
|
|
3209
|
+
* - **#3345's** (persisted-but-unreported): a field `applyStatePreservation`
|
|
3210
|
+
* restored that the transform never touched at all must still be
|
|
3211
|
+
* reported — preservation can mutate a field the pre-sync intent never
|
|
3212
|
+
* knew about.
|
|
3213
|
+
*
|
|
3214
|
+
* @param preSyncContent The transformFn's OWN return value — the content
|
|
3215
|
+
* BEFORE `syncAndPreserveStateMd` ran this write — captured by the caller
|
|
3216
|
+
* inside its own `readModifyWriteStateMd` callback. Comparing that against
|
|
3217
|
+
* the actual bytes on disk after the full write pipeline settled is what
|
|
3218
|
+
* makes the report reflect what POST-sync bytes hold (ADR-3408 §8.4),
|
|
3219
|
+
* not what the pre-sync intent merely hoped for.
|
|
3220
|
+
* @param reported The candidate field names — the transform's OWN
|
|
3221
|
+
* success list (e.g. `beginPhaseCore`'s `updated`), never a raw intent
|
|
3222
|
+
* list the transform might not have actually matched. Body Title-Case
|
|
3223
|
+
* labels (`Status`, `Current Plan`) and frontmatter keys are both valid;
|
|
3224
|
+
* each is looked up as a frontmatter key first, else as a body field —
|
|
3225
|
+
* the same fallback chain `cmdStatePatch` used before this generalization.
|
|
3226
|
+
* @param divergedFields Frontmatter field names `applyStatePreservation`
|
|
3227
|
+
* actually restored during this write (ADR-3408 §8.5's out-param).
|
|
3228
|
+
*/
|
|
3229
|
+
function reconcileReportedFields(statePath, preSyncContent, reported, divergedFields) {
|
|
3230
|
+
const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
3231
|
+
const persistedFm = extractFrontmatter(persisted, statePath);
|
|
3232
|
+
const persistedBody = stripFrontmatter(persisted);
|
|
3233
|
+
const preFm = extractFrontmatter(preSyncContent, statePath);
|
|
3234
|
+
const preBody = stripFrontmatter(preSyncContent);
|
|
3235
|
+
// #3471 review: body-FIRST, frontmatter-fallback — mirrors the actual write
|
|
3236
|
+
// precedence `patchCore`/`updateCore` apply (their own docstrings: "a key
|
|
3237
|
+
// that resolves against the STRIPPED body ... wins deterministically even
|
|
3238
|
+
// when the same key also happens to exist as a parsed frontmatter key").
|
|
3239
|
+
// The prior frontmatter-first order was silently correct for every
|
|
3240
|
+
// Title-Case body label (`Status`, `Current Plan`, ...) only because those
|
|
3241
|
+
// never case-exact-match a frontmatter key (frontmatter keys are always
|
|
3242
|
+
// lowercase snake_case) — so `hasOwnProperty` always missed and it fell
|
|
3243
|
+
// through to the body anyway. It broke the one case where `field` IS
|
|
3244
|
+
// lowercase and DOES exact-match a frontmatter key: a table-format
|
|
3245
|
+
// STATE.md with a lowercase field name (e.g. `state update status ...`
|
|
3246
|
+
// against `| status | ... |`). There, `preFm` (extracted from the
|
|
3247
|
+
// transform's own pre-sync output) never has a `status` key yet — but
|
|
3248
|
+
// `persistedFm` (extracted after `syncStateFrontmatter` ran) always does,
|
|
3249
|
+
// since `status` is a schema-owned frontmatter key re-derived on every
|
|
3250
|
+
// write. Reading frontmatter first made `intended` (body text) and
|
|
3251
|
+
// `persistedValue` (frontmatter-derived enum) compare two different
|
|
3252
|
+
// representations of the same field, and the write was never reconciled
|
|
3253
|
+
// (regression: #1162's "state update is case-insensitive for table field
|
|
3254
|
+
// names").
|
|
3255
|
+
const valueOf = (fm, body, field) => {
|
|
3256
|
+
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
3257
|
+
if (bodyValue !== null)
|
|
3258
|
+
return bodyValue;
|
|
3259
|
+
return Object.prototype.hasOwnProperty.call(fm, field) ? String(fm[field]) : null;
|
|
3260
|
+
};
|
|
3261
|
+
const reconciled = [];
|
|
3262
|
+
for (const field of reported) {
|
|
3263
|
+
const intended = valueOf(preFm, preBody, field);
|
|
3264
|
+
const persistedValue = valueOf(persistedFm, persistedBody, field);
|
|
3265
|
+
if (intended !== null && intended.trim() === (persistedValue ?? '').trim()) {
|
|
3266
|
+
reconciled.push(field);
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
// #3471 review: only fold a `divergedFields` entry into the reported array
|
|
3270
|
+
// when it is a `preserve-when-unchanged` row (has a genuine body-line
|
|
3271
|
+
// label — Status, Current Plan, Current Phase, ...). #3345's direction
|
|
3272
|
+
// ("preservation restored a field the intent never named") is about a
|
|
3273
|
+
// caller-visible BODY field the transform could plausibly have named —
|
|
3274
|
+
// never about `progress` (`preserve-always`) or `milestone`/`milestone_name`
|
|
3275
|
+
// (`preserve-if-placeholder`), which are structured/paired fields no
|
|
3276
|
+
// caller ever names via a per-field body label and whose restoration is
|
|
3277
|
+
// the long-standing, silent #3242/#948 protection, not a caller-visible
|
|
3278
|
+
// "update". Folding them in unconditionally reported `progress` as
|
|
3279
|
+
// `updated` on every `state.patch`/`state.update` write that happened to
|
|
3280
|
+
// preserve it — even when the call never touched Current Phase's
|
|
3281
|
+
// curated-progress-preserving field at all (regression: #1264's
|
|
3282
|
+
// `state.patch` of `Current Phase` reporting `updated: ['Current Phase',
|
|
3283
|
+
// 'progress']` instead of `['Current Phase']`).
|
|
3284
|
+
for (const field of divergedFields) {
|
|
3285
|
+
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3286
|
+
if (!cls || cls.preservation !== 'preserve-when-unchanged')
|
|
3287
|
+
continue;
|
|
3288
|
+
const label = bodyLabelFor(field);
|
|
3289
|
+
if (!reconciled.includes(label))
|
|
3290
|
+
reconciled.push(label);
|
|
3291
|
+
}
|
|
3292
|
+
return reconciled;
|
|
3293
|
+
}
|
|
2123
3294
|
function cmdStateJson(cwd, raw) {
|
|
2124
3295
|
const statePath = planningPaths(cwd).state;
|
|
2125
3296
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -2132,28 +3303,67 @@ function cmdStateJson(cwd, raw) {
|
|
|
2132
3303
|
// Always rebuild from body + disk so progress counters reflect current state.
|
|
2133
3304
|
// Returning cached frontmatter directly causes stale percent/completed_plans
|
|
2134
3305
|
// when SUMMARY files were added after the last STATE.md write (#1589).
|
|
2135
|
-
|
|
2136
|
-
//
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
//
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
3306
|
+
// #3354: pass the stored total so the milestoned-but-unbounded withhold can
|
|
3307
|
+
// report the preserved value instead of omitting the key.
|
|
3308
|
+
// #3573: pass the STORED MILESTONE too (same parity reasoning) — otherwise the
|
|
3309
|
+
// roadmap-absent withhold never fires on this read surface and `state json`
|
|
3310
|
+
// reports the phase-directory count while the persisted file preserves the
|
|
3311
|
+
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
|
3312
|
+
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
3313
|
+
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
|
|
3314
|
+
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
|
3315
|
+
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
|
3316
|
+
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
|
3317
|
+
// third private copy of the empty-only guards with no delta/staleness check
|
|
3318
|
+
// at all — the shape that let a stale-but-present body annotation always
|
|
3319
|
+
// beat a fresher curated frontmatter value in `state json` output (#3395's
|
|
3320
|
+
// shape outside the write seam).
|
|
3321
|
+
//
|
|
3322
|
+
// `cmdStateJson` never writes — it is one snapshot read, not a
|
|
3323
|
+
// before/after transform — so "did THIS write change the body source"
|
|
3324
|
+
// (the #1230 delta the executor consults) is definitionally "no": every
|
|
3325
|
+
// field's body source is passed as its own delta pre/post pair (the same
|
|
3326
|
+
// value twice). That is what makes the executor's rule resolve to
|
|
3327
|
+
// "restore the curated value whenever a real one exists" here — exactly
|
|
3328
|
+
// §8.5's "same terms as an empty derived value" extended to a present
|
|
3329
|
+
// one, i.e. the exact D3 fix. Deliberately scoped to only these six
|
|
3330
|
+
// fields (not the full `applyStatePreservation` dispatch loop): `progress`
|
|
3331
|
+
// (preserve-always) keeps its own `shouldPreserveExistingProgress`
|
|
3332
|
+
// cross-milestone rule below — a DIFFERENT policy that must survive this
|
|
3333
|
+
// change untouched — and `milestone`/`milestone_name`
|
|
3334
|
+
// (preserve-if-placeholder) are out of D3's scope entirely.
|
|
3335
|
+
if (existingFm) {
|
|
3336
|
+
const sessionScope = matchSessionSection(body) ?? body;
|
|
3337
|
+
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
3338
|
+
const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
|
|
3339
|
+
const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
|
|
3340
|
+
const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
|
|
3341
|
+
const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
|
|
3342
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
3343
|
+
const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
3344
|
+
const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
|
|
3345
|
+
const unchanged = (v) => ({ pre: v, post: v });
|
|
3346
|
+
const ctx = {
|
|
3347
|
+
preFm: null,
|
|
3348
|
+
postFm: built,
|
|
3349
|
+
preFmSnapshot: existingFm,
|
|
3350
|
+
resync: true,
|
|
3351
|
+
deriveProgressKeys: false,
|
|
3352
|
+
bodyDeltas: {
|
|
3353
|
+
status: unchanged(bodyStatus),
|
|
3354
|
+
stopped_at: unchanged(bodyStoppedAt),
|
|
3355
|
+
paused_at: unchanged(bodyPausedAt),
|
|
3356
|
+
current_phase: unchanged(bodyCurrentPhase),
|
|
3357
|
+
current_plan: unchanged(bodyCurrentPlan),
|
|
3358
|
+
current_phase_name: unchanged(bodyPhaseSource),
|
|
3359
|
+
},
|
|
3360
|
+
mutated: false,
|
|
3361
|
+
};
|
|
3362
|
+
for (const field of ['status', 'stopped_at', 'paused_at', 'current_phase', 'current_plan', 'current_phase_name']) {
|
|
3363
|
+
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3364
|
+
if (cls)
|
|
3365
|
+
stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
|
|
3366
|
+
}
|
|
2157
3367
|
}
|
|
2158
3368
|
// Preserve curated cross-milestone aggregates when local disk scanning sees
|
|
2159
3369
|
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
|
|
@@ -2193,7 +3403,6 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
2193
3403
|
};
|
|
2194
3404
|
const deps = {
|
|
2195
3405
|
clock: clock_cjs_1.realClock,
|
|
2196
|
-
progressProvider: () => null, // beginPhase doesn't consult disk progress; syncStateFrontmatter's scan is authoritative
|
|
2197
3406
|
sourcePath: statePath,
|
|
2198
3407
|
};
|
|
2199
3408
|
// #2736: the transition holds the exact display name; without this the
|
|
@@ -2202,13 +3411,27 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
2202
3411
|
// that itself contains a parenthetical. The #1695 delta-gate preservation
|
|
2203
3412
|
// still runs after the sync; the override is re-asserted after it inside
|
|
2204
3413
|
// readModifyWriteStateMd for layouts with no body `Phase:` line.
|
|
3414
|
+
const divergedFields = [];
|
|
2205
3415
|
const rmwOptions = {
|
|
2206
3416
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3417
|
+
divergedFields,
|
|
2207
3418
|
};
|
|
2208
|
-
let
|
|
3419
|
+
let precomputedUpdated = [];
|
|
3420
|
+
let preSyncContent = '';
|
|
3421
|
+
// #3311: begin-phase is the claim point — it is the one Current Position
|
|
3422
|
+
// transition that explicitly names its phase, so it both records this
|
|
3423
|
+
// session's claim and detects a conflicting live claim for a different
|
|
3424
|
+
// phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
|
|
3425
|
+
// calls cannot both read "no claim" and both write.
|
|
3426
|
+
let milestoneConflict = null;
|
|
2209
3427
|
readModifyWriteStateMd(statePath, (content) => {
|
|
3428
|
+
milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
|
|
3429
|
+
if (milestoneConflict) {
|
|
3430
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
|
|
3431
|
+
}
|
|
2210
3432
|
const result = transitionCore(content, intent, deps);
|
|
2211
|
-
|
|
3433
|
+
precomputedUpdated = result.updated;
|
|
3434
|
+
preSyncContent = result.content;
|
|
2212
3435
|
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
|
|
2213
3436
|
// the intent-first override must not fire — it would drift frontmatter
|
|
2214
3437
|
// away from the preserved body value. Dropping it here is safe because
|
|
@@ -2218,7 +3441,12 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
2218
3441
|
}
|
|
2219
3442
|
return result.content;
|
|
2220
3443
|
}, cwd, rmwOptions);
|
|
2221
|
-
|
|
3444
|
+
// ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
|
|
3445
|
+
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
|
3446
|
+
// field preservation restored that this transform never touched (#3345's
|
|
3447
|
+
// direction).
|
|
3448
|
+
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
|
|
3449
|
+
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
|
|
2222
3450
|
}
|
|
2223
3451
|
/**
|
|
2224
3452
|
* Write a WAITING.json signal file when GSD hits a decision point.
|
|
@@ -2325,7 +3553,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
|
|
|
2325
3553
|
// direction (#1659): canonicalize a numeric phase to its integer form so a seeded
|
|
2326
3554
|
// "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
|
|
2327
3555
|
const phaseNumStr = String(phaseNum);
|
|
2328
|
-
const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr);
|
|
3556
|
+
const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : (0, pattern_cjs_1.escapeRegex)(phaseNumStr);
|
|
2329
3557
|
const phaseCellRe = new RegExp(`^${canonCell}$`, 'i');
|
|
2330
3558
|
const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
|
|
2331
3559
|
const before = content.slice(0, tableStart);
|
|
@@ -2441,7 +3669,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
|
|
|
2441
3669
|
* Gate 3a: Record state after plan-phase completes.
|
|
2442
3670
|
* Updates Status to "Ready to execute", Total Plans, Last Activity.
|
|
2443
3671
|
*/
|
|
2444
|
-
function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
3672
|
+
function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
2445
3673
|
const statePath = planningPaths(cwd).state;
|
|
2446
3674
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
2447
3675
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -2458,19 +3686,40 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
2458
3686
|
const intent = {
|
|
2459
3687
|
kind: 'plannedPhase',
|
|
2460
3688
|
phaseNumber,
|
|
3689
|
+
phaseName: phaseName ?? null,
|
|
2461
3690
|
planCount: planCount ?? null,
|
|
2462
3691
|
};
|
|
2463
3692
|
const deps = {
|
|
2464
3693
|
clock: clock_cjs_1.realClock,
|
|
2465
|
-
progressProvider: () => null,
|
|
2466
3694
|
sourcePath: statePath,
|
|
2467
3695
|
};
|
|
2468
|
-
|
|
3696
|
+
// #3395 / #2736: the transition holds the exact display name. plannedPhaseCore
|
|
3697
|
+
// writes it into the Current Position `Phase: N (Name) — READY TO EXECUTE`
|
|
3698
|
+
// line, and the prose re-derivation of current_phase_name truncates names
|
|
3699
|
+
// that themselves contain a parenthetical — the authoritative override keeps
|
|
3700
|
+
// the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
|
|
3701
|
+
const divergedFields = [];
|
|
3702
|
+
const rmwOptions = {
|
|
3703
|
+
resync: false,
|
|
3704
|
+
deriveProgressKeys: true,
|
|
3705
|
+
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3706
|
+
divergedFields,
|
|
3707
|
+
};
|
|
3708
|
+
let precomputedUpdated = [];
|
|
3709
|
+
let preSyncContent = '';
|
|
2469
3710
|
readModifyWriteStateMd(statePath, (content) => {
|
|
2470
3711
|
const result = transitionCore(content, intent, deps);
|
|
2471
|
-
|
|
3712
|
+
precomputedUpdated = result.updated;
|
|
3713
|
+
preSyncContent = result.content;
|
|
2472
3714
|
return result.content;
|
|
2473
|
-
}, cwd,
|
|
3715
|
+
}, cwd, rmwOptions);
|
|
3716
|
+
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
|
3717
|
+
// against the bytes actually persisted (fix(#3351) generalized) and fold
|
|
3718
|
+
// in any field preservation restored that this transform never touched
|
|
3719
|
+
// (#3345's direction) — traced for this phase (design doc: "not traced in
|
|
3720
|
+
// the analysis pass") and found to need exactly the same treatment as
|
|
3721
|
+
// `cmdStateBeginPhase`.
|
|
3722
|
+
const updated = reconcileReportedFields(statePath, preSyncContent, precomputedUpdated, divergedFields);
|
|
2474
3723
|
const result = updated.length === 0
|
|
2475
3724
|
? { updated, phase: phaseNumber, plan_count: planCount, warning: 'STATE.md Current Position has no recognized labels — transition was a no-op. Verify STATE.md uses the canonical labeled format (Status:, Total Plans in Phase:, etc.).' }
|
|
2476
3725
|
: { updated, phase: phaseNumber, plan_count: planCount };
|
|
@@ -2496,7 +3745,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
2496
3745
|
// milestoneSwitch rebuilds frontmatter directly and must not run the
|
|
2497
3746
|
// steady-state syncStateFrontmatter post-sync.
|
|
2498
3747
|
const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
|
|
2499
|
-
const deps = { clock: clock_cjs_1.realClock,
|
|
3748
|
+
const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
|
|
2500
3749
|
const lockPath = acquireStateLock(statePath);
|
|
2501
3750
|
try {
|
|
2502
3751
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
@@ -2510,8 +3759,98 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
2510
3759
|
}
|
|
2511
3760
|
/**
|
|
2512
3761
|
* Gate 1: Validate STATE.md against filesystem.
|
|
2513
|
-
* Returns { valid, warnings, drift } JSON.
|
|
3762
|
+
* Returns { valid, warnings, drift, scope } JSON.
|
|
3763
|
+
*
|
|
3764
|
+
* #3187 (ADR-3180 §7.7, Decisions 2-4): two defects fixed here.
|
|
3765
|
+
*
|
|
3766
|
+
* (1) #3162 THE HEADLINE. Every warning this function can emit used to be
|
|
3767
|
+
* gated behind `if (currentPhase && fs.existsSync(phasesDir))`, and
|
|
3768
|
+
* `currentPhase` came from a body-only `stateExtractField(content, 'Current
|
|
3769
|
+
* Phase')` call with no frontmatter fallback. A STATE.md whose phase lives
|
|
3770
|
+
* ONLY in frontmatter therefore resolved `currentPhase` to `null`, the whole
|
|
3771
|
+
* drift block was skipped, and the function returned
|
|
3772
|
+
* `{valid:true, warnings:[], drift:{}}` — "could not look" was
|
|
3773
|
+
* output-identical to "looked, all clean." Current Phase / Status / Total
|
|
3774
|
+
* Plans in Phase now route through `stateFieldValue` (the single owner of the
|
|
3775
|
+
* #1760 frontmatter-then-body fallback chain), so the frontmatter tier is
|
|
3776
|
+
* actually consulted.
|
|
3777
|
+
*
|
|
3778
|
+
* (2) #1255 FRONTMATTER SHADOWING. The old code passed UNSTRIPPED `content`
|
|
3779
|
+
* to the extractor. `stateExtractField`'s plain-format branch is
|
|
3780
|
+
* `^Field:` with the `i` flag, so a frontmatter `status:` key matched the
|
|
3781
|
+
* pattern for the body field `Status` and won, because the frontmatter block
|
|
3782
|
+
* precedes the body. Parsed once now — `extractFrontmatter` +
|
|
3783
|
+
* `stripFrontmatter` — and `fm`/`body` are handed to the chain owner, exactly
|
|
3784
|
+
* as `advancePlanCore`/`beginPhaseCore`/`completePhaseCore`/
|
|
3785
|
+
* `readModifyWriteStateMd` already guard against this class of defect.
|
|
3786
|
+
*
|
|
3787
|
+
* `scope` (ADR-3180 Decision 2) reports whether the derivation actually ran:
|
|
3788
|
+
* - `COMPLETE` — the phase-vs-disk derivation ran over usable input,
|
|
3789
|
+
* including when it legitimately finds no VERIFICATION.md / no matching
|
|
3790
|
+
* phase directory (a real answer, not a non-answer).
|
|
3791
|
+
* - `UNSCOPED` — Current Phase could not be resolved by ANY chain step (no
|
|
3792
|
+
* frontmatter scalar, no body field), so the drift derivation had no
|
|
3793
|
+
* phase to scope its disk lookup to and could not run at all. Reporting
|
|
3794
|
+
* this as COMPLETE would recreate the #3162 collapse this phase closes,
|
|
3795
|
+
* one layer out.
|
|
3796
|
+
* - `UNREADABLE` — the frontmatter parse or the phases-dir scan itself
|
|
3797
|
+
* could not be consulted (an existing `catch` block used to swallow this
|
|
3798
|
+
* silently; the degrade stays, but is now visible).
|
|
3799
|
+
*
|
|
3800
|
+
* ⛔ Rejected (ADR-3180 §7.7 Rejected #2): a non-`COMPLETE` scope is never
|
|
3801
|
+
* routed to `valid:false`. `valid` keeps meaning "no drift warnings were
|
|
3802
|
+
* found"; `scope` says whether the derivation could actually run. A caller
|
|
3803
|
+
* branches on both — folding them into one boolean recreates the exact
|
|
3804
|
+
* collapse this epic removes, in the opposite direction (a legacy STATE.md
|
|
3805
|
+
* with no resolvable phase is a supported degrade, not an invalid document).
|
|
3806
|
+
*/
|
|
3807
|
+
/**
|
|
3808
|
+
* #1255/#3187: parse frontmatter and strip it from the body ONCE, shared by
|
|
3809
|
+
* `cmdStateValidate` and `cmdStateCompletePhase` so both consult the identical
|
|
3810
|
+
* fm/body precedence and degrade identically when the frontmatter half of the
|
|
3811
|
+
* chain cannot be consulted. Extracted (code-review finding, epic #3180): the
|
|
3812
|
+
* two call sites previously carried a byte-identical try/catch, comments
|
|
3813
|
+
* included — an epic whose own thesis is "one canonical owner per
|
|
3814
|
+
* derivation" must not ship a duplicated derivation in its own diff.
|
|
3815
|
+
*
|
|
3816
|
+
* Returns `scope: SCOPE.COMPLETE` unless the frontmatter parse itself threw,
|
|
3817
|
+
* in which case `fm` degrades to `{}` and `scope` becomes `SCOPE.UNREADABLE`
|
|
3818
|
+
* — callers that mutate `scope` further (e.g. `cmdStateValidate`'s later
|
|
3819
|
+
* UNSCOPED/disk-scan degrades) start from this returned value rather than a
|
|
3820
|
+
* fresh `SCOPE.COMPLETE`.
|
|
2514
3821
|
*/
|
|
3822
|
+
function readStateFrontmatterScoped(content, statePath) {
|
|
3823
|
+
let fm;
|
|
3824
|
+
let scope = SCOPE.COMPLETE;
|
|
3825
|
+
try {
|
|
3826
|
+
fm = extractFrontmatter(content, statePath);
|
|
3827
|
+
}
|
|
3828
|
+
catch {
|
|
3829
|
+
// extractFrontmatter is documented never to throw, but this mirrors the
|
|
3830
|
+
// defensive try/catch already used around it elsewhere in this file
|
|
3831
|
+
// (e.g. spliceFrontmatter) — a parse hiccup here means the frontmatter
|
|
3832
|
+
// half of the chain could not be consulted; degrade visibly.
|
|
3833
|
+
fm = {};
|
|
3834
|
+
scope = SCOPE.UNREADABLE;
|
|
3835
|
+
}
|
|
3836
|
+
const body = stripFrontmatter(content);
|
|
3837
|
+
return { fm, body, scope };
|
|
3838
|
+
}
|
|
3839
|
+
/**
|
|
3840
|
+
* Builds an S0NN `Diagnostic` for `cmdStateValidate` (§8.4 rule 3 —
|
|
3841
|
+
* `cmdStateValidate` is a plain imperative function, not a `Rule.check`, so
|
|
3842
|
+
* it builds `Diagnostic[]` directly rather than going through
|
|
3843
|
+
* `evaluateRuleTable`/the `RULES` array machinery). Every S0NN subject is
|
|
3844
|
+
* advisory-only today (`cmdStateValidate` has never had a repair path), so
|
|
3845
|
+
* every remedy is `adviseRemedy` — `advice` is the short imperative command
|
|
3846
|
+
* text shown to the operator, matching the style Phase 11's rule-group files
|
|
3847
|
+
* already use for their own ADVISE-only findings (e.g.
|
|
3848
|
+
* `roadmap-disk-consistency.cts`'s `adviseRemedy('Create phase directory or
|
|
3849
|
+
* remove from roadmap')`).
|
|
3850
|
+
*/
|
|
3851
|
+
function stateDiagnostic(code, severity, message, advice) {
|
|
3852
|
+
return { code, severity, message, remedy: adviseRemedy(advice) };
|
|
3853
|
+
}
|
|
2515
3854
|
function cmdStateValidate(cwd, raw) {
|
|
2516
3855
|
const statePath = planningPaths(cwd).state;
|
|
2517
3856
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -2524,67 +3863,126 @@ function cmdStateValidate(cwd, raw) {
|
|
|
2524
3863
|
// searchers downstream, reading as "absent" rather than "corrupt."
|
|
2525
3864
|
const encErr = (0, validate_cjs_1.textEncodingError)(content, 'STATE.md');
|
|
2526
3865
|
if (encErr) {
|
|
2527
|
-
|
|
3866
|
+
// S001 — error-class severity (this branch has always set `valid: false`
|
|
3867
|
+
// unconditionally and returned immediately, matching every other
|
|
3868
|
+
// error-class code, not a mere warning). Message reused verbatim from
|
|
3869
|
+
// `textEncodingError`, not paraphrased.
|
|
3870
|
+
output({
|
|
3871
|
+
valid: false,
|
|
3872
|
+
warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
|
|
3873
|
+
}, raw, undefined);
|
|
2528
3874
|
return;
|
|
2529
3875
|
}
|
|
2530
3876
|
const warnings = [];
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
3877
|
+
// #1255/#3187: parse frontmatter and strip it from the body ONCE, so the
|
|
3878
|
+
// chain owner sees the same fm/body precedence every other migrated call
|
|
3879
|
+
// site sees. Pass statePath so a truncated STATE.md is named in the #1882
|
|
3880
|
+
// diagnostic rather than reported under a content digest.
|
|
3881
|
+
const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
|
|
3882
|
+
const scope = initialScope;
|
|
3883
|
+
const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
|
|
3884
|
+
const resolvedPhase = resolveStatePhase(fm, body);
|
|
3885
|
+
const currentPhase = resolvedPhase.phase;
|
|
3886
|
+
const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
|
|
2535
3887
|
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
2536
3888
|
const phasesDir = planningPaths(cwd).phases;
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
3889
|
+
if (currentPhase === null) {
|
|
3890
|
+
warnings.push(stateDiagnostic('S002', SEVERITY.WARNING, 'Cannot validate phase drift: STATE.md has no usable current_phase, Current Phase, or Current Position Phase value', 'Set current_phase (frontmatter) or Current Phase / Current Position Phase (body) in STATE.md'));
|
|
3891
|
+
output({ valid: false, warnings, scope }, raw, undefined);
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
|
3895
|
+
if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
|
|
3896
|
+
warnings.push(stateDiagnostic('S003', SEVERITY.WARNING, `Phase reference conflict: validating authoritative phase ${currentPhase}; align STATE.md phase sources`, 'Align STATE.md phase sources (frontmatter, Current Phase, Current Position Phase) on one phase'));
|
|
3897
|
+
}
|
|
3898
|
+
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
3899
|
+
warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is missing for phase ${currentPhase}`, 'Create the phases directory or correct current_phase to a phase that exists on disk'));
|
|
3900
|
+
output({ valid: false, warnings, scope }, raw, undefined);
|
|
3901
|
+
return;
|
|
3902
|
+
}
|
|
3903
|
+
let phaseDirPath;
|
|
3904
|
+
try {
|
|
3905
|
+
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
3906
|
+
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
|
|
3907
|
+
if (!phaseDir) {
|
|
3908
|
+
warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: no phase directory matches phase ${currentPhase}`, 'Create a phase directory matching the current phase or correct current_phase'));
|
|
3909
|
+
output({ valid: false, warnings, scope }, raw, undefined);
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
|
|
3913
|
+
}
|
|
3914
|
+
catch {
|
|
3915
|
+
warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phases directory is unreadable for phase ${currentPhase}`, 'Check phases directory permissions and re-run validate'));
|
|
3916
|
+
output({ valid: false, warnings, scope }, raw, undefined);
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
try {
|
|
3920
|
+
const scan = scanPhasePlans(phaseDirPath);
|
|
3921
|
+
if (scan.scope !== SCOPE.COMPLETE) {
|
|
3922
|
+
throw new Error('phase plan scan is incomplete');
|
|
3923
|
+
}
|
|
3924
|
+
const { planCount: diskPlans, summaryCount: diskSummaries } = scan;
|
|
3925
|
+
// Check plan count mismatch
|
|
3926
|
+
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
|
|
3927
|
+
warnings.push(stateDiagnostic('S005', SEVERITY.WARNING, `Plan count mismatch: STATE.md says ${totalPlansInPhase} plans, disk has ${diskPlans}`, 'Run state sync or correct Total Plans in Phase to match the plans on disk'));
|
|
3928
|
+
}
|
|
3929
|
+
// Check for VERIFICATION.md — scoped to THIS phase's own token (#3511)
|
|
3930
|
+
// so a stray, cross-phase, or ad-hoc VERIFICATION file cannot claim
|
|
3931
|
+
// this phase's status has drifted.
|
|
3932
|
+
//
|
|
3933
|
+
// WARNING-4 (#3511 review): the pre-filter grammar here is
|
|
3934
|
+
// deliberately BROADER than the `-VERIFICATION.md` suffix every
|
|
3935
|
+
// other site in the codebase uses — `.includes('VERIFICATION')`
|
|
3936
|
+
// admits names like `03_VERIFICATION.md` (underscore, no dash) that
|
|
3937
|
+
// the dashed grammar would reject outright. That breadth predates
|
|
3938
|
+
// #3511 and is intentional here (this is a best-effort drift
|
|
3939
|
+
// WARNING scan, not an authoritative single-pick resolver), so it is
|
|
3940
|
+
// left as-is rather than narrowed to match the dashed sites — doing
|
|
3941
|
+
// so would be a separate, un-asked-for behavior change (S006/S007).
|
|
3942
|
+
// What #3511 DOES change is that a name this broader grammar admits
|
|
3943
|
+
// is now ALSO subject to the same `scopeToPhase` membership check as
|
|
3944
|
+
// every dashed-grammar site, so a stray `04_VERIFICATION.md`-shaped
|
|
3945
|
+
// file in phase 03's directory is excluded exactly like a stray
|
|
3946
|
+
// `04-VERIFICATION.md` would be — while `03_VERIFICATION.md` (own
|
|
3947
|
+
// phase, underscore separator) is NOT excluded: `isPhaseArtifact`
|
|
3948
|
+
// (`phase-id.cts`) accepts `_` as a candidate-boundary separator
|
|
3949
|
+
// alongside `-` and `.` for exactly this reason, so an S006/S007
|
|
3950
|
+
// scan of `03-alpha/03_VERIFICATION.md` still resolves to S006
|
|
3951
|
+
// ("verification passed" drift), not a false S007.
|
|
3952
|
+
const files = node_fs_1.default.readdirSync(phaseDirPath);
|
|
3953
|
+
const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
|
|
3954
|
+
const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName);
|
|
3955
|
+
for (const vf of verificationFiles) {
|
|
3956
|
+
try {
|
|
3957
|
+
const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
|
|
3958
|
+
if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
|
|
3959
|
+
warnings.push(stateDiagnostic('S006', SEVERITY.WARNING, `Status drift: STATE.md says "${status}" but ${vf} shows verification passed — phase may be complete`, 'Run state complete-phase (or otherwise advance STATE.md status past "executing")'));
|
|
2574
3960
|
}
|
|
2575
3961
|
}
|
|
3962
|
+
catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
|
|
3963
|
+
* warnings scan across N VERIFICATION.md files — one unreadable file
|
|
3964
|
+
* (permission/race) must not abort the scan of the rest; it's simply
|
|
3965
|
+
* excluded from drift detection. Does not degrade `scope` — the other
|
|
3966
|
+
* N-1 files were consulted fine. */
|
|
3967
|
+
}
|
|
2576
3968
|
}
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
3969
|
+
// Check if all plans have summaries but status still says executing
|
|
3970
|
+
if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
|
|
3971
|
+
// Only warn if no verification exists (if verification passed, the above warning covers it)
|
|
3972
|
+
if (verificationFiles.length === 0) {
|
|
3973
|
+
// S007 stays WARNING (not INFO): closely related to S006 (both
|
|
3974
|
+
// signal "phase may be ready to advance"), and S006 is WARNING —
|
|
3975
|
+
// giving the sibling condition a different severity for the same
|
|
3976
|
+
// underlying signal would be a false distinction.
|
|
3977
|
+
warnings.push(stateDiagnostic('S007', SEVERITY.WARNING, `All ${diskPlans} plans have summaries but status is still "${status}" — phase may be ready for verification`, 'Run phase verification, then advance STATE.md status past "executing"'));
|
|
3978
|
+
}
|
|
2584
3979
|
}
|
|
2585
3980
|
}
|
|
3981
|
+
catch {
|
|
3982
|
+
warnings.push(stateDiagnostic('S004', SEVERITY.WARNING, `Cannot validate phase drift: phase directory is unreadable for phase ${currentPhase}`, 'Check phase directory permissions and re-run validate'));
|
|
3983
|
+
}
|
|
2586
3984
|
const valid = warnings.length === 0;
|
|
2587
|
-
output({ valid, warnings,
|
|
3985
|
+
output({ valid, warnings, scope }, raw, undefined);
|
|
2588
3986
|
}
|
|
2589
3987
|
/**
|
|
2590
3988
|
* Gate 2: Sync STATE.md from filesystem ground truth.
|
|
@@ -2644,10 +4042,17 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2644
4042
|
let _highestIncompletePhaseSummaryCount = 0;
|
|
2645
4043
|
for (const dir of entries) {
|
|
2646
4044
|
const dirPath = node_path_1.default.join(phasesDir, dir);
|
|
2647
|
-
const { planCount: plans, summaryCount: summaries
|
|
4045
|
+
const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
|
|
2648
4046
|
totalDiskPlans += plans;
|
|
2649
4047
|
totalDiskSummaries += summaries;
|
|
2650
|
-
|
|
4048
|
+
// ADR-3180 §7.4 (#3186, #2957 disk-strict): route through the single
|
|
4049
|
+
// canonical owner (isPhaseComplete), not scanPhasePlans's own `completed`
|
|
4050
|
+
// field ("are all plans summarized?" — a different question). This is the
|
|
4051
|
+
// same fix buildStateFrontmatter got above; cmdStateSync (`state sync`)
|
|
4052
|
+
// was a second, independent consumer of the same raw field the initial
|
|
4053
|
+
// migration missed — without it, `state sync` and `state json` disagreed
|
|
4054
|
+
// on completed_phases for the identical disk state.
|
|
4055
|
+
if (isPhaseComplete(dirPath).value.complete)
|
|
2651
4056
|
diskCompletedPhases++;
|
|
2652
4057
|
// Track the highest phase with incomplete plans (or any plans)
|
|
2653
4058
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
@@ -2708,18 +4113,37 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2708
4113
|
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
|
|
2709
4114
|
let milestoneBounded = true;
|
|
2710
4115
|
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
2711
|
-
|
|
2712
|
-
|
|
4116
|
+
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
|
|
4117
|
+
// a hand-rolled, unbounded-substring re-derivation — see the identical
|
|
4118
|
+
// fix in buildStateFrontmatter above.
|
|
4119
|
+
milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
|
|
2713
4120
|
}
|
|
2714
4121
|
let percent = null;
|
|
2715
4122
|
if (!milestoneBounded) {
|
|
2716
4123
|
changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
|
|
2717
4124
|
}
|
|
2718
4125
|
else {
|
|
2719
|
-
|
|
2720
|
-
|
|
4126
|
+
// #3217 (ADR-3180 §7.6 rule 4) BLOCKER fix: the prior comment here claimed
|
|
4127
|
+
// `entries` (the raw fs.readdirSync listing above) was "never routed
|
|
4128
|
+
// through listMilestonePhaseDirs, so there is no real Scope to pass" —
|
|
4129
|
+
// that was factually wrong. The same `syncRoadmapRaw`/`syncRoadmapScope`
|
|
4130
|
+
// already parsed above (~3104) is precisely what
|
|
4131
|
+
// `listMilestonePhaseDirs` (via `getMilestonePhaseFilter`) re-derives
|
|
4132
|
+
// from `cwd` to produce a real `Scope` — the identical shape already
|
|
4133
|
+
// threaded through `buildStateFrontmatter`'s `diskScope` above. Calling
|
|
4134
|
+
// it here (discarding `.value`, which duplicates `entries`'s own
|
|
4135
|
+
// retired-phase-filtered listing) gets the real scope without changing
|
|
4136
|
+
// the disk-scan totals computed above.
|
|
4137
|
+
const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
|
|
4138
|
+
if (syncScope !== SCOPE.COMPLETE) {
|
|
4139
|
+
changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
|
|
4140
|
+
}
|
|
4141
|
+
else {
|
|
4142
|
+
const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases, syncScope);
|
|
4143
|
+
percent = p !== null ? p : 0;
|
|
4144
|
+
}
|
|
2721
4145
|
}
|
|
2722
|
-
const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock
|
|
4146
|
+
const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock });
|
|
2723
4147
|
modified = syncResult.content;
|
|
2724
4148
|
const coreChanges = syncResult.data?.changes ?? [];
|
|
2725
4149
|
changes.push(...coreChanges);
|
|
@@ -2751,30 +4175,18 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2751
4175
|
}
|
|
2752
4176
|
const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
|
|
2753
4177
|
const dryRun = !!options.dryRun;
|
|
2754
|
-
// Resolve the current phase via the
|
|
2755
|
-
//
|
|
2756
|
-
//
|
|
2757
|
-
// "Only 0
|
|
2758
|
-
// #
|
|
2759
|
-
//
|
|
2760
|
-
// fallback matches any `| Phase | N |` row (e.g. a historical verification
|
|
2761
|
-
// table), resolving a stale phase and computing a wrong cutoff. Frontmatter and
|
|
2762
|
-
// the explicit `Current Phase` field are unambiguous, so they stay document-wide;
|
|
2763
|
-
// the shared extractor is not narrowed for any other caller.
|
|
4178
|
+
// Resolve the current phase via `resolveCurrentPhaseId` — the shared owner of
|
|
4179
|
+
// the canonical frontmatter → `Current Phase` field → scoped prose ladder
|
|
4180
|
+
// (#1760 origin, #1776 scoping, #3187 ownership; see its doc comment). Prune
|
|
4181
|
+
// engages on a template-conformant STATE.md instead of bailing "Only 0
|
|
4182
|
+
// phases" (#1760). #3231/#3481 routed the phase-labeled write commands
|
|
4183
|
+
// through the same helper rather than leaving a second copy of the ladder here.
|
|
2764
4184
|
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2765
4185
|
const fm = extractFrontmatter(rawState, statePath);
|
|
2766
4186
|
const body = stripFrontmatter(rawState);
|
|
2767
|
-
//
|
|
2768
|
-
//
|
|
2769
|
-
|
|
2770
|
-
const fmRawPhase = fm.current_phase;
|
|
2771
|
-
const fmCurrentPhase = typeof fmRawPhase === 'string' ? (fmRawPhase.trim() || null)
|
|
2772
|
-
: typeof fmRawPhase === 'number' || typeof fmRawPhase === 'boolean' ? String(fmRawPhase)
|
|
2773
|
-
: null;
|
|
2774
|
-
const positionSection = sliceCurrentPositionSection(body);
|
|
2775
|
-
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionSection, 'Phase')).phase : null;
|
|
2776
|
-
const currentPhaseRaw = fmCurrentPhase ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase;
|
|
2777
|
-
const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
|
|
4187
|
+
// Prune needs an integer cutoff, so it parses the resolved id itself; a
|
|
4188
|
+
// non-numeric or absent id lands on 0 and prune bails, as before.
|
|
4189
|
+
const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body)), 10) || 0;
|
|
2778
4190
|
const cutoff = currentPhase - keepRecent;
|
|
2779
4191
|
if (cutoff <= 0) {
|
|
2780
4192
|
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
|
|
@@ -2787,7 +4199,7 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2787
4199
|
// This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase`
|
|
2788
4200
|
// fallback above), dry-run, and STATE-ARCHIVE.md writes.
|
|
2789
4201
|
const runPruneCore = (content) => {
|
|
2790
|
-
const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock
|
|
4202
|
+
const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: clock_cjs_1.realClock });
|
|
2791
4203
|
return {
|
|
2792
4204
|
newContent: result.content,
|
|
2793
4205
|
archivedSections: (result.data?.archivedSections) ?? [],
|
|
@@ -2866,11 +4278,25 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2866
4278
|
// is the same canonical source `buildStateFrontmatter` consults; the Leaky-
|
|
2867
4279
|
// Abstractions guard in `rebuildCore` (ADR-1817 §1) keeps the pure core
|
|
2868
4280
|
// testable without this dep — here we provide it.
|
|
4281
|
+
//
|
|
4282
|
+
// #3057 B1: a missing `.planning/phases/` directory is genuinely "nothing
|
|
4283
|
+
// to reconcile" (`ok:true, phases: []`) — but a `readdirSync`/`statSync`
|
|
4284
|
+
// THROW on a directory that DOES exist (permission fault, corrupted
|
|
4285
|
+
// mount, etc.) is a real scan failure (`ok:false`). The old implementation
|
|
4286
|
+
// returned `null` for both, so `state rebuild` could report success while
|
|
4287
|
+
// by-phase-table reconciliation silently never ran. Per-entry stat
|
|
4288
|
+
// failures (an individual phase dir vanishing mid-scan) still `continue`
|
|
4289
|
+
// past that one entry — that is not a whole-scan failure.
|
|
2869
4290
|
const phaseInventoryProvider = () => {
|
|
2870
4291
|
try {
|
|
2871
4292
|
const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
|
|
2872
4293
|
if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
|
|
2873
|
-
return
|
|
4294
|
+
return { ok: true, phases: [] };
|
|
4295
|
+
// #3185: deliberately NOT listMilestonePhaseDirs. `state rebuild` is a
|
|
4296
|
+
// RECONCILIATION pass against ground truth -- it must see every phase
|
|
4297
|
+
// directory on disk so an orphan STATE.md row for a phase that no longer
|
|
4298
|
+
// exists (or sits outside the current window) is dropped. Scoping this
|
|
4299
|
+
// would make the rebuild silently preserve stale rows.
|
|
2874
4300
|
const entries = node_fs_1.default.readdirSync(phasesDir);
|
|
2875
4301
|
const records = [];
|
|
2876
4302
|
for (const entry of entries) {
|
|
@@ -2888,19 +4314,30 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2888
4314
|
const m = entry.match(/^(\d+)-(.+)$/);
|
|
2889
4315
|
if (!m)
|
|
2890
4316
|
continue;
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
4317
|
+
// #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source
|
|
4318
|
+
// planCount/summaryCount from the single owner (scanPhasePlans)
|
|
4319
|
+
// instead of a local root-only `-PLAN.md`/`-SUMMARY.md` readdirSync
|
|
4320
|
+
// filter — picks up bare PLAN.md/SUMMARY.md and nested plans/. A
|
|
4321
|
+
// non-COMPLETE scope (TRUNCATED: nested plans/ unreadable;
|
|
4322
|
+
// UNREADABLE: `full` itself unreadable) is not a trustworthy count —
|
|
4323
|
+
// throw so it surfaces via the outer catch as a real scan failure
|
|
4324
|
+
// (`ok:false`), mirroring the #3057 B1 contract documented above for
|
|
4325
|
+
// the sibling `fs.readdirSync(phasesDir)` failure mode, rather than
|
|
4326
|
+
// silently reporting an undercount.
|
|
4327
|
+
const scan = scanPhasePlans(full);
|
|
4328
|
+
if (scan.scope !== SCOPE.COMPLETE) {
|
|
4329
|
+
throw new Error(`could not fully scan plan directory (scope ${scan.scope}): ${full}`);
|
|
4330
|
+
}
|
|
4331
|
+
const { planCount, summaryCount } = scan;
|
|
2894
4332
|
records.push({ number: m[1], name: m[2], planCount, summaryCount });
|
|
2895
4333
|
}
|
|
2896
|
-
return records;
|
|
4334
|
+
return { ok: true, phases: records };
|
|
2897
4335
|
}
|
|
2898
|
-
catch {
|
|
2899
|
-
return
|
|
4336
|
+
catch (err) {
|
|
4337
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
2900
4338
|
}
|
|
2901
4339
|
};
|
|
2902
4340
|
const deps = {
|
|
2903
|
-
progressProvider: () => null,
|
|
2904
4341
|
clock: clock_cjs_1.realClock,
|
|
2905
4342
|
phaseInventoryProvider,
|
|
2906
4343
|
// Without this, `state rebuild --dry-run` reported a truncated STATE.md anonymously: the
|
|
@@ -2919,18 +4356,25 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2919
4356
|
process.stderr.write(`[rebuild] ${JSON.stringify(entry)}\n`);
|
|
2920
4357
|
}
|
|
2921
4358
|
};
|
|
4359
|
+
const scanFailureNote = (reason) => 'Nothing rebuilt: the phase-inventory disk scan failed, so by-phase-table reconciliation did not run' +
|
|
4360
|
+
(reason ? ` (${reason})` : '');
|
|
2922
4361
|
if (dryRun) {
|
|
2923
4362
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2924
4363
|
const result = runRebuild(content);
|
|
2925
4364
|
const data = (result.data ?? {});
|
|
2926
4365
|
emitVerboseLog(data.log);
|
|
2927
4366
|
const mutated = data.mutated === true;
|
|
4367
|
+
const scanFailed = data.phase_inventory_scan_failed === true;
|
|
2928
4368
|
emit({
|
|
2929
4369
|
rebuilt: false,
|
|
2930
4370
|
dry_run: true,
|
|
2931
4371
|
mutations: Array.isArray(data.log) ? data.log.length : 0,
|
|
2932
4372
|
mutated,
|
|
2933
|
-
|
|
4373
|
+
phase_inventory_scan_failed: scanFailed,
|
|
4374
|
+
phase_inventory_scan_reason: scanFailed ? data.phase_inventory_scan_reason : undefined,
|
|
4375
|
+
note: mutated
|
|
4376
|
+
? 'Run without --dry-run to apply changes'
|
|
4377
|
+
: scanFailed ? scanFailureNote(data.phase_inventory_scan_reason) : 'Nothing to rebuild',
|
|
2934
4378
|
}, raw, mutated ? 'true' : 'false');
|
|
2935
4379
|
return;
|
|
2936
4380
|
}
|
|
@@ -2939,18 +4383,26 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2939
4383
|
// to STATE.md by rebuildCore itself, per ADR-1817 §3).
|
|
2940
4384
|
let capturedLog = [];
|
|
2941
4385
|
let capturedMutated = false;
|
|
4386
|
+
let capturedScanFailed = false;
|
|
4387
|
+
let capturedScanReason;
|
|
2942
4388
|
readModifyWriteStateMd(statePath, (content) => {
|
|
2943
4389
|
const result = runRebuild(content);
|
|
2944
4390
|
const data = (result.data ?? {});
|
|
2945
4391
|
capturedLog = Array.isArray(data.log) ? data.log : [];
|
|
2946
4392
|
capturedMutated = data.mutated === true;
|
|
4393
|
+
capturedScanFailed = data.phase_inventory_scan_failed === true;
|
|
4394
|
+
capturedScanReason = data.phase_inventory_scan_reason;
|
|
2947
4395
|
return result.content;
|
|
2948
4396
|
}, cwd);
|
|
2949
4397
|
emitVerboseLog(capturedLog);
|
|
2950
4398
|
emit({
|
|
2951
4399
|
rebuilt: capturedMutated,
|
|
2952
4400
|
mutations: capturedLog.length,
|
|
2953
|
-
|
|
4401
|
+
phase_inventory_scan_failed: capturedScanFailed,
|
|
4402
|
+
phase_inventory_scan_reason: capturedScanFailed ? capturedScanReason : undefined,
|
|
4403
|
+
note: capturedMutated
|
|
4404
|
+
? 'STATE.md rebuilt; see ## Rebuild Log section for the audit trail'
|
|
4405
|
+
: capturedScanFailed ? scanFailureNote(capturedScanReason) : 'Nothing to rebuild',
|
|
2954
4406
|
}, raw, capturedMutated ? 'true' : 'false');
|
|
2955
4407
|
}
|
|
2956
4408
|
/**
|
|
@@ -2959,10 +4411,17 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2959
4411
|
* that the phase execution is finished and the project is ready for the next phase.
|
|
2960
4412
|
* Implements the `gsd state complete-phase` subcommand (issue #2735).
|
|
2961
4413
|
*/
|
|
2962
|
-
function resolvePhaseIdForCompletePhase(
|
|
4414
|
+
function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
|
|
4415
|
+
// #3187: route through the single #1760 fallback-chain owner (fm scalar
|
|
4416
|
+
// then body field) instead of two raw stateExtractField calls on
|
|
4417
|
+
// frontmatter-blind content — a STATE.md whose phase lives only in
|
|
4418
|
+
// frontmatter no longer resolves to null here. `Phase` (the historical
|
|
4419
|
+
// second-choice field name) has no frontmatter counterpart, so its fmKey
|
|
4420
|
+
// is null — same shape as cmdStateSnapshot's `stateFieldValue(fm,
|
|
4421
|
+
// currentPositionScope, null, 'Phase')` fallback.
|
|
2963
4422
|
const candidate = overridePhase ||
|
|
2964
|
-
(0, state_document_cjs_1.
|
|
2965
|
-
(0, state_document_cjs_1.
|
|
4423
|
+
(0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ||
|
|
4424
|
+
(0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Phase').value ||
|
|
2966
4425
|
'';
|
|
2967
4426
|
// #2125: parse via the canonical anchored parser so a narrative `Phase:`
|
|
2968
4427
|
// body line (e.g. "Milestone v0.5 complete") does not mine a bogus token —
|
|
@@ -2979,7 +4438,30 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2979
4438
|
return;
|
|
2980
4439
|
}
|
|
2981
4440
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2982
|
-
|
|
4441
|
+
// #1255/#3187: parse frontmatter and strip it from the body ONCE, mirroring
|
|
4442
|
+
// cmdStateValidate/cmdStateSnapshot, so resolvePhaseIdForCompletePhase and
|
|
4443
|
+
// the idempotency guard below consult the identical fm/body precedence —
|
|
4444
|
+
// the two sites cannot drift onto different chains, extending the #2125
|
|
4445
|
+
// "same canonical parser" guarantee one layer earlier.
|
|
4446
|
+
const { fm, body, scope } = readStateFrontmatterScoped(content, statePath);
|
|
4447
|
+
// #3187 Postel/visibility (design doc's sharpest case): this whole handler
|
|
4448
|
+
// is the DESTRUCTIVE path the #3489 idempotency guard below protects — it
|
|
4449
|
+
// decides whether a re-run of `state complete-phase --phase N` is allowed
|
|
4450
|
+
// to roll STATE.md back to N's moment-of-completion. If the frontmatter
|
|
4451
|
+
// half of the chain could not be consulted (`scope` UNREADABLE),
|
|
4452
|
+
// `existingCurrentPhase` below could read as null even though the
|
|
4453
|
+
// project's true current phase lives only in that unreadable frontmatter —
|
|
4454
|
+
// silently treating a non-COMPLETE scope as "not complete" would let the
|
|
4455
|
+
// guard's `existingCurrentPhase &&` check fail OPEN and re-run an
|
|
4456
|
+
// already-completed phase. Refuse outright instead of guessing; this
|
|
4457
|
+
// applies even when `--phase` is explicit, because the guard's job is to
|
|
4458
|
+
// protect against exactly that already-completed-phase case regardless of
|
|
4459
|
+
// how the target phase was named.
|
|
4460
|
+
if (scope !== SCOPE.COMPLETE) {
|
|
4461
|
+
output({ error: 'Unable to read STATE.md frontmatter; refusing to run complete-phase to avoid a destructive rollback (#3489). Fix or remove the malformed frontmatter and retry.' }, raw, undefined);
|
|
4462
|
+
return;
|
|
4463
|
+
}
|
|
4464
|
+
const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase);
|
|
2983
4465
|
if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
|
|
2984
4466
|
output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
|
|
2985
4467
|
return;
|
|
@@ -2993,7 +4475,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
2993
4475
|
// Last Activity, Last Activity Description, and the Current Position body.
|
|
2994
4476
|
// The handler is now a no-op in that case so re-invocation from downstream
|
|
2995
4477
|
// workflows cannot regress the project state.
|
|
2996
|
-
const existingCurrentPhaseRaw = (0, state_document_cjs_1.
|
|
4478
|
+
const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
|
|
2997
4479
|
// #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
|
|
2998
4480
|
// sites cannot diverge on the token they extract.
|
|
2999
4481
|
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
|
|
@@ -3002,7 +4484,18 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3002
4484
|
return;
|
|
3003
4485
|
}
|
|
3004
4486
|
const today = clock_cjs_1.realClock.localToday();
|
|
4487
|
+
// #3408 review (close-known-limits): `updated` mixes two different kinds of
|
|
4488
|
+
// thing — FIELD names (Status, Last Activity, ...), each reconcilable
|
|
4489
|
+
// against the persisted bytes via `reconcileReportedFields`, and the
|
|
4490
|
+
// SECTION name `Current Position` (the whole Current-Position block, not a
|
|
4491
|
+
// single field `stateExtractField` can look up). Rather than re-deriving
|
|
4492
|
+
// the distinction downstream by string-matching against a Set, each entry
|
|
4493
|
+
// now carries its kind at the point it is PRODUCED; the flattening to a
|
|
4494
|
+
// flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
|
|
4495
|
+
// below, right before `output()`.
|
|
3005
4496
|
const updated = [];
|
|
4497
|
+
let preSyncContent = '';
|
|
4498
|
+
const divergedFields = [];
|
|
3006
4499
|
readModifyWriteStateMd(statePath, (content) => {
|
|
3007
4500
|
const currentPhase = resolvedPhase;
|
|
3008
4501
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
@@ -3016,20 +4509,20 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3016
4509
|
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
3017
4510
|
if (result) {
|
|
3018
4511
|
body = result;
|
|
3019
|
-
updated.push('Status');
|
|
4512
|
+
updated.push({ kind: 'field', name: 'Status' });
|
|
3020
4513
|
}
|
|
3021
4514
|
// Update Last Activity date
|
|
3022
4515
|
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
|
|
3023
4516
|
if (result) {
|
|
3024
4517
|
body = result;
|
|
3025
|
-
updated.push('Last Activity');
|
|
4518
|
+
updated.push({ kind: 'field', name: 'Last Activity' });
|
|
3026
4519
|
}
|
|
3027
4520
|
// Update Last Activity Description
|
|
3028
4521
|
const activityDesc = `Phase ${currentPhase} marked complete`;
|
|
3029
4522
|
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
3030
4523
|
if (result) {
|
|
3031
4524
|
body = result;
|
|
3032
|
-
updated.push('Last Activity Description');
|
|
4525
|
+
updated.push({ kind: 'field', name: 'Last Activity Description' });
|
|
3033
4526
|
}
|
|
3034
4527
|
// Update ## Current Position section
|
|
3035
4528
|
// ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
|
|
@@ -3088,12 +4581,30 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3088
4581
|
posBody = replaced;
|
|
3089
4582
|
}
|
|
3090
4583
|
body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd);
|
|
3091
|
-
updated.push('Current Position');
|
|
4584
|
+
updated.push({ kind: 'section', name: 'Current Position' });
|
|
3092
4585
|
}
|
|
3093
4586
|
}
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
4587
|
+
const out = reassemble(body);
|
|
4588
|
+
preSyncContent = out;
|
|
4589
|
+
return out;
|
|
4590
|
+
}, cwd, { divergedFields });
|
|
4591
|
+
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
|
|
4592
|
+
// the analysis pass"). Unlike the transitionCore-based commands, this
|
|
4593
|
+
// adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
|
|
4594
|
+
// Activity Description — each reconcilable against the persisted bytes,
|
|
4595
|
+
// same as every other command in this phase) with the SECTION entry
|
|
4596
|
+
// `Current Position` (the whole Current-Position block, not a single
|
|
4597
|
+
// field `stateExtractField` can look up — reconciling it the same way as
|
|
4598
|
+
// a field would always drop it as a false negative). Reconcile only the
|
|
4599
|
+
// field-shaped entries (#3351's direction), pass the section entry
|
|
4600
|
+
// through unconditionally, and fold in any field preservation restored
|
|
4601
|
+
// that this transform never touched (#3345's direction). The kind was
|
|
4602
|
+
// decided at PUSH time above (typed producer), not re-derived here by
|
|
4603
|
+
// string-matching a name against a Set.
|
|
4604
|
+
const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
|
|
4605
|
+
const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
|
|
4606
|
+
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preSyncContent, fieldEntries, divergedFields)];
|
|
4607
|
+
output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
|
|
3097
4608
|
}
|
|
3098
4609
|
module.exports = {
|
|
3099
4610
|
stateExtractField: state_document_cjs_1.stateExtractField,
|
|
@@ -3104,6 +4615,17 @@ module.exports = {
|
|
|
3104
4615
|
writeStateMd,
|
|
3105
4616
|
readModifyWriteStateMd,
|
|
3106
4617
|
syncStateFrontmatter,
|
|
4618
|
+
// #3374: the shared post-sync preservation pass (snapshots + table-driven
|
|
4619
|
+
// applyStatePreservation + #2736 re-assert).
|
|
4620
|
+
applyPostSyncPreservation,
|
|
4621
|
+
// #3469 (ADR-3408 §8.3): the ONE write-seam composition (sync +
|
|
4622
|
+
// preservation) as content -> content. Exported for cmdPhaseComplete's
|
|
4623
|
+
// atomic-commit adapter (phase.cts, syncs STATE.md directly because it is
|
|
4624
|
+
// committed atomically with ROADMAP/REQUIREMENTS) and for
|
|
4625
|
+
// cmdMilestoneComplete (milestone.cts) — both need the composition's
|
|
4626
|
+
// output but supply their own I/O envelope around it.
|
|
4627
|
+
syncAndPreserveStateMd,
|
|
4628
|
+
readStateHeadFreshness,
|
|
3107
4629
|
withStateLock,
|
|
3108
4630
|
updatePerformanceMetricsSection,
|
|
3109
4631
|
cmdStateLoad,
|
|
@@ -3133,6 +4655,10 @@ module.exports = {
|
|
|
3133
4655
|
// Test seam (#1514): the pure retired/folded-phase parser, exposed so its
|
|
3134
4656
|
// strikethrough-detection logic can be property-tested directly.
|
|
3135
4657
|
_extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
|
|
4658
|
+
// Test seam (#3471 review): the second hand-maintained table beside
|
|
4659
|
+
// FIELD_CLASSIFICATION, exposed so a parity test can pin that every
|
|
4660
|
+
// `preserve-when-unchanged` row has a label here.
|
|
4661
|
+
_FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
|
|
3136
4662
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
3137
4663
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
3138
4664
|
_setLockProbes(probes) {
|