@opengsd/gsd-core 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +12 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +22 -14
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +5 -3
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +23 -2
- package/agents/gsd-plan-checker.md +50 -53
- package/agents/gsd-planner.md +50 -50
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +13 -4
- package/bin/install.js +1448 -1103
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +9 -5
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1035 -138
- package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
- package/gsd-core/bin/lib/api-coverage.cjs +33 -14
- package/gsd-core/bin/lib/artifacts.cjs +5 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +1026 -268
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- 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-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +845 -130
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +700 -40
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +216 -42
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +1172 -108
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
- package/gsd-core/bin/lib/config-loader.cjs +187 -23
- package/gsd-core/bin/lib/config.cjs +102 -3
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +208 -33
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +899 -229
- package/gsd-core/bin/lib/gap-checker.cjs +95 -10
- package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- 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 +149 -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 +268 -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 +187 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +439 -168
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +811 -259
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
- package/gsd-core/bin/lib/install-profiles.cjs +212 -61
- 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-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +170 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +183 -22
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +842 -73
- package/gsd-core/bin/lib/model-catalog.cjs +232 -16
- package/gsd-core/bin/lib/model-resolver.cjs +193 -68
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
- package/gsd-core/bin/lib/phase-id.cjs +514 -40
- package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
- package/gsd-core/bin/lib/phase-locator.cjs +262 -34
- package/gsd-core/bin/lib/phase.cjs +1038 -214
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +98 -3
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
- package/gsd-core/bin/lib/probe-core.cjs +5 -2
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
- package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +442 -96
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
- package/gsd-core/bin/lib/smart-entry.cjs +133 -23
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +52 -19
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +338 -8
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +846 -176
- package/gsd-core/bin/lib/state.cjs +2589 -369
- package/gsd-core/bin/lib/surface.cjs +33 -11
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- 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 +67 -23
- package/gsd-core/bin/lib/uat.cjs +1761 -167
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
- package/gsd-core/bin/lib/unusable-input.cjs +37 -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-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +75 -0
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -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 +272 -9
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +453 -918
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +44 -26
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +39 -21
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +4 -6
- package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
- 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 +17 -11
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +5 -4
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +61 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +44 -13
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +5 -5
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -17
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +5 -4
- package/gsd-core/templates/state.md +11 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +13 -20
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +11 -27
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +64 -5
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
- package/gsd-core/workflows/code-review-fix.md +38 -11
- package/gsd-core/workflows/code-review.md +159 -52
- package/gsd-core/workflows/complete-milestone.md +151 -23
- package/gsd-core/workflows/debug.md +12 -8
- package/gsd-core/workflows/diagnose-issues.md +47 -15
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +27 -2
- package/gsd-core/workflows/eval-review.md +7 -14
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- 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 +24 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +72 -100
- package/gsd-core/workflows/execute-plan.md +52 -15
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +76 -10
- package/gsd-core/workflows/import.md +18 -15
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +49 -16
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +8 -5
- package/gsd-core/workflows/new-milestone.md +22 -29
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +26 -40
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +14 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +162 -59
- package/gsd-core/workflows/plan-review-convergence.md +96 -11
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +61 -18
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
- package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
- package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +55 -44
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +219 -20
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +69 -7
- package/gsd-core/workflows/ship.md +116 -50
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +53 -13
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +64 -9
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +49 -14
- package/gsd-core/workflows/ui-phase.md +15 -21
- package/gsd-core/workflows/ui-review.md +8 -12
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +7 -11
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +66 -25
- package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
- package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +37 -27
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +55 -32
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +231 -24
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +162 -46
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- 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 +9 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +158 -30
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +2 -3
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +83 -3
- package/hooks/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +37 -27
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +55 -32
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +231 -24
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +162 -46
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +28 -11
- package/pi/gsd.cjs +19 -5
- package/scripts/base64-scan.sh +74 -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 +5 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +64 -14
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +11 -21
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +138 -25
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +308 -0
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- 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-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-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +492 -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 +471 -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 +488 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +950 -0
- package/scripts/lint-test-file-count.allowlist.json +137 -8
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +830 -0
- package/scripts/lint-vendored-deps.cjs +297 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +100 -14
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +24 -7
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +9 -5
- 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/bin/lib/ui-safety-gate.cjs +0 -107
- 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 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -11,18 +11,26 @@ 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;
|
|
17
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const cliExitModule = require("./cli-exit.cjs");
|
|
20
|
+
const { ExitError } = cliExitModule;
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
|
+
const stateContract = require("./state-contract.cjs");
|
|
23
|
+
const { publishStateContract } = stateContract;
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
18
25
|
const configLoaderMod = require("./config-loader.cjs");
|
|
19
26
|
const { loadConfig } = configLoaderMod;
|
|
20
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
28
|
const phaseIdMod = require("./phase-id.cjs");
|
|
22
|
-
const {
|
|
29
|
+
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
|
|
23
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
31
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
25
|
-
|
|
32
|
+
// #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
|
|
33
|
+
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection } = roadmapParserMod;
|
|
26
34
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
27
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
36
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
@@ -30,16 +38,57 @@ const { planningDir, planningPaths } = planningWorkspace;
|
|
|
30
38
|
const clock_cjs_1 = require("./clock.cjs");
|
|
31
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
40
|
const frontmatter = require("./frontmatter.cjs");
|
|
33
|
-
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
|
|
41
|
+
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel, FRONTMATTER_UNPARSEABLE } = frontmatter;
|
|
42
|
+
/**
|
|
43
|
+
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
|
|
44
|
+
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a frontmatter-fenced region
|
|
45
|
+
* exists but failed to parse (malformed YAML, or a refused anchor/alias/merge key)? Mirrors
|
|
46
|
+
* `state-transition.cts`'s private helper of the same name/shape — kept local rather than
|
|
47
|
+
* exported+imported because the two modules' `existingFm` values come from independent
|
|
48
|
+
* `extractFrontmatter` calls and this predicate is a two-line symbol read, not shared state.
|
|
49
|
+
*/
|
|
50
|
+
function isUnparseableFrontmatter(existingFm) {
|
|
51
|
+
return existingFm[FRONTMATTER_UNPARSEABLE] === true;
|
|
52
|
+
}
|
|
34
53
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
54
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
36
55
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
|
+
const verificationMod = require("./verification.cjs");
|
|
57
|
+
const { isPhaseComplete } = verificationMod;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
60
|
+
const { SCOPE } = planningScopeMod;
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
|
+
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
63
|
+
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
65
|
const stateTransitionMod = require("./state-transition.cjs");
|
|
66
|
+
// #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
|
|
67
|
+
// projection of this leaf schema rather than a hand-maintained literal.
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
69
|
+
const stateMdSchemaMod = require("./state-md-schema.cjs");
|
|
70
|
+
// #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
|
|
71
|
+
// node builtins, so it introduces no cycle on this path.
|
|
72
|
+
const project_root_cjs_1 = require("./project-root.cjs");
|
|
73
|
+
// #3311: advisory (phase, session) claim over the single Current Position slot.
|
|
74
|
+
// Imports only node builtins + planning-workspace + active-workstream-store, so
|
|
75
|
+
// it introduces no cycle on this path.
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
|
+
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
38
78
|
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
|
79
|
+
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
|
|
80
|
+
// failure explanation, and the classification table it falls back to.
|
|
81
|
+
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
|
|
82
|
+
// ADR-3473 §8.7 (#3872): the declared dotted-leaf enumeration `reconcileReportedFields`
|
|
83
|
+
// diffs against — see `declaredLeavesOf` below.
|
|
84
|
+
const { FIELD_CLASSIFICATION } = stateTransitionMod;
|
|
39
85
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
40
86
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
41
87
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
42
88
|
const validate_cjs_1 = require("./validate.cjs");
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
90
|
+
const healthDiagnosticTypesMod = require("./health-diagnostic-types.cjs");
|
|
91
|
+
const { SEVERITY, adviseRemedy } = healthDiagnosticTypesMod;
|
|
43
92
|
const STATE_PROGRESS_RESYNC_FIELDS = new Set([
|
|
44
93
|
'Progress',
|
|
45
94
|
'Total Plans in Phase',
|
|
@@ -212,7 +261,7 @@ function cmdStateLoad(cwd, raw) {
|
|
|
212
261
|
`state_exists=${stateExists}`,
|
|
213
262
|
];
|
|
214
263
|
process.stdout.write(lines.join('\n'));
|
|
215
|
-
|
|
264
|
+
throw new ExitError(0);
|
|
216
265
|
}
|
|
217
266
|
output(result, false, undefined);
|
|
218
267
|
}
|
|
@@ -229,7 +278,7 @@ function cmdStateGet(cwd, section, raw) {
|
|
|
229
278
|
return;
|
|
230
279
|
}
|
|
231
280
|
// Try to find markdown section or field
|
|
232
|
-
const fieldEscaped = escapeRegex(section);
|
|
281
|
+
const fieldEscaped = (0, pattern_cjs_1.escapeRegex)(section);
|
|
233
282
|
// Check for **field:** value (bold format)
|
|
234
283
|
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
|
|
235
284
|
const boldMatch = content.match(boldPattern);
|
|
@@ -290,18 +339,87 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
290
339
|
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
|
|
291
340
|
// delta (table-driven) that this phase adds. Field-name validation (security)
|
|
292
341
|
// and the resync-progress decision stay in this adapter.
|
|
293
|
-
let
|
|
342
|
+
let precomputed = { updated: [], failed: [] };
|
|
343
|
+
const divergedFields = [];
|
|
344
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
345
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
346
|
+
const preWriteState = {};
|
|
294
347
|
readModifyWriteStateMd(statePath, (content) => {
|
|
295
348
|
const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
|
|
296
|
-
|
|
349
|
+
precomputed = result.data ?? precomputed;
|
|
297
350
|
return result.content;
|
|
298
|
-
}, cwd, { resync: shouldResync });
|
|
351
|
+
}, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync, preWriteState });
|
|
352
|
+
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
|
|
353
|
+
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
|
354
|
+
// MATCHED — but its plain-line pattern (`m` flag over the full document)
|
|
355
|
+
// can match the YAML frontmatter line for a lower-cased key, and the write
|
|
356
|
+
// pipeline (syncStateFrontmatter re-derivation + the FIELD_CLASSIFICATION
|
|
357
|
+
// preservation rows) then discards or restores that text before the file is
|
|
358
|
+
// saved. A field is only reported `updated` when its post-write on-disk
|
|
359
|
+
// value equals what THIS transform actually wrote (the frontmatter key
|
|
360
|
+
// when present, else the body field — the legitimate working case for
|
|
361
|
+
// state.patch is display-cased BODY fields — Status, Current Plan, Phase —
|
|
362
|
+
// which are never frontmatter keys). Also folds in any field
|
|
363
|
+
// `applyStatePreservation` restored that this patch never named at all
|
|
364
|
+
// (#3345's direction), a case the pre-#3471 version of this command never
|
|
365
|
+
// covered.
|
|
366
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
|
|
367
|
+
const updatedSet = new Set(updated);
|
|
368
|
+
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
|
|
369
|
+
const results = { updated, failed };
|
|
299
370
|
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
|
300
371
|
}
|
|
301
372
|
catch {
|
|
302
373
|
error('STATE.md not found');
|
|
303
374
|
}
|
|
304
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Why did `state update <field>` not write anything?
|
|
378
|
+
*
|
|
379
|
+
* #3699: this used to be one sentence — `Field "X" not found in STATE.md` — for
|
|
380
|
+
* every falsy outcome, so a PRESENT-but-derived frontmatter key and a genuinely
|
|
381
|
+
* absent field produced byte-identical output apart from the name. The classifier
|
|
382
|
+
* already knew the difference; the message threw it away, and worse, pointed away
|
|
383
|
+
* from the route that works.
|
|
384
|
+
*
|
|
385
|
+
* Four distinct answers, because there are four distinct situations:
|
|
386
|
+
* 1. a body-derived frontmatter key whose body source EXISTS → name that source
|
|
387
|
+
* 2. a frontmatter key with no body source at all (disk/external/clock-derived)
|
|
388
|
+
* → say what derives it, and do not invent a body field to blame
|
|
389
|
+
* 3. a body field that feeds a frontmatter key still carrying a value
|
|
390
|
+
* → name the key, so case D is diagnosable rather than a bare absence
|
|
391
|
+
* 4. genuinely unknown → unchanged
|
|
392
|
+
*/
|
|
393
|
+
function explainUpdateFailure(field) {
|
|
394
|
+
const bodySource = getFrontmatterBodySource(field);
|
|
395
|
+
if (bodySource) {
|
|
396
|
+
// (1) The fallback in `updateCore` did not fire, so a body source line
|
|
397
|
+
// exists — that is the writable route.
|
|
398
|
+
const [primary] = bodySource;
|
|
399
|
+
return `Field "${field}" is a body-derived frontmatter key and is not directly writable. `
|
|
400
|
+
+ `Update its body source instead: state update "${primary}" <value>.`;
|
|
401
|
+
}
|
|
402
|
+
const classification = getFieldClassification(field);
|
|
403
|
+
if (classification) {
|
|
404
|
+
// (2) Known key, no body source: disk/external/free-derived.
|
|
405
|
+
const derivedFrom = {
|
|
406
|
+
disk: 'derived from a scan of .planning/phases/ and is not directly writable',
|
|
407
|
+
external: 'derived from ROADMAP.md and is not directly writable',
|
|
408
|
+
free: 'recomputed on every write and is not directly writable',
|
|
409
|
+
curated: 'maintained by the write path and is not directly writable through this command',
|
|
410
|
+
body: 'body-derived and is not directly writable',
|
|
411
|
+
};
|
|
412
|
+
return `Field "${field}" is a frontmatter key that is ${derivedFrom[classification.source]}.`;
|
|
413
|
+
}
|
|
414
|
+
const owningKey = frontmatterKeyForBodyField(field);
|
|
415
|
+
if (owningKey) {
|
|
416
|
+
// (3) Case D from the body-field side.
|
|
417
|
+
return `Field "${field}" not found in STATE.md. It is the body source for frontmatter key `
|
|
418
|
+
+ `"${owningKey}" — add the "${field}:" line to the body, or update "${owningKey}" directly `
|
|
419
|
+
+ `to repair a document whose body source is missing.`;
|
|
420
|
+
}
|
|
421
|
+
return `Field "${field}" not found in STATE.md`; // (4) genuinely unknown
|
|
422
|
+
}
|
|
305
423
|
function cmdStateUpdate(cwd, field, value) {
|
|
306
424
|
if (!field || value === undefined) {
|
|
307
425
|
error('field and value required for state update');
|
|
@@ -316,6 +434,31 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
316
434
|
const statePath = planningPaths(cwd).state;
|
|
317
435
|
try {
|
|
318
436
|
let updated = false;
|
|
437
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
438
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
439
|
+
const preWriteState = {};
|
|
440
|
+
let transitionData;
|
|
441
|
+
// #3699 case D: when `updateCore` falls back to writing the frontmatter key
|
|
442
|
+
// directly, the value must survive the post-sync pass — otherwise the write
|
|
443
|
+
// is silently undone. `buildStateFrontmatter` re-derives `stopped_at` from
|
|
444
|
+
// the body, finds no source, and emits nothing; `applyPreserveWhenUnchanged`
|
|
445
|
+
// then sees an unchanged (absent) body source and restores the PRE-write
|
|
446
|
+
// snapshot over the value just written. Verified: without this the command
|
|
447
|
+
// reported `updated:false` with `preserved:["Stopped At"]` and the old value
|
|
448
|
+
// stood.
|
|
449
|
+
//
|
|
450
|
+
// `authoritativeFm` is the seam built for exactly this (#2736 — "intent-first
|
|
451
|
+
// frontmatter values … so the lossy body-prose re-derivation can never
|
|
452
|
+
// destroy information the transition just resolved"), and it is re-applied
|
|
453
|
+
// AFTER preservation (`applyPostSyncPreservation`), so it wins the restore.
|
|
454
|
+
//
|
|
455
|
+
// Populated by the transform below rather than up front, because only the
|
|
456
|
+
// transition knows whether the fallback fired. Safe: `readModifyWriteStateMd`
|
|
457
|
+
// dereferences `options.authoritativeFm` after running the transform. Left
|
|
458
|
+
// empty when the fallback does not fire — an empty object iterates zero
|
|
459
|
+
// entries and is a no-op at both application sites.
|
|
460
|
+
const authoritativeFm = {};
|
|
461
|
+
const divergedFields = [];
|
|
319
462
|
const shouldResync = shouldResyncStateProgress([field]);
|
|
320
463
|
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
|
321
464
|
// body-strip/reassemble single-field update is the pure `updateCore` in
|
|
@@ -326,13 +469,50 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
326
469
|
readModifyWriteStateMd(statePath, (content) => {
|
|
327
470
|
const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
|
|
328
471
|
updated = result.data?.updated === true;
|
|
472
|
+
transitionData = result.data;
|
|
473
|
+
if (transitionData?.wroteFrontmatter === true) {
|
|
474
|
+
authoritativeFm[field] = value;
|
|
475
|
+
}
|
|
329
476
|
return result.content;
|
|
330
|
-
}, cwd, { resync: shouldResync });
|
|
477
|
+
}, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync, preWriteState });
|
|
478
|
+
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
|
479
|
+
// `updateCore`'s own match does not know whether sync/preservation later
|
|
480
|
+
// discarded the value it wrote (#3351's direction, generalized from
|
|
481
|
+
// `cmdStatePatch`). `preserved` folds in any OTHER field preservation
|
|
482
|
+
// restored during this write that this command never touched at all
|
|
483
|
+
// (#3345's direction) — reported separately from `updated` because this
|
|
484
|
+
// command's contract is a single-field boolean, not a per-field array.
|
|
485
|
+
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
|
|
486
|
+
updated = reconciled.includes(field);
|
|
487
|
+
const preserved = reconciled.filter((f) => f !== field);
|
|
331
488
|
if (updated) {
|
|
332
|
-
|
|
489
|
+
// #3699 case D: surfaced so a caller can tell "wrote the body source" from
|
|
490
|
+
// "wrote the frontmatter key because no body source existed" — the second
|
|
491
|
+
// is a repair, and silently reporting it as an ordinary update is the same
|
|
492
|
+
// class of unfalsifiable success this issue is about.
|
|
493
|
+
const wroteFrontmatter = transitionData?.wroteFrontmatter === true;
|
|
494
|
+
if (!wroteFrontmatter) {
|
|
495
|
+
output({ updated: true, preserved }, false, undefined);
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
// `preserved` reports the BODY LABEL of each field preservation restored
|
|
499
|
+
// (`bodyLabelFor`, the #3345 direction). In the case-D fallback that
|
|
500
|
+
// reading is stale by one step: preservation DID restore this field's
|
|
501
|
+
// snapshot, and `authoritativeFm` then overrode it, so the value on disk
|
|
502
|
+
// is the one just written. Reporting it as preserved would claim a
|
|
503
|
+
// restore that did not survive — the same unfalsifiable-success shape
|
|
504
|
+
// #3699 is about, one field over. Drop this field's own labels; other
|
|
505
|
+
// fields' preservation is untouched and still reported.
|
|
506
|
+
const ownLabels = new Set((getFrontmatterBodySource(field) ?? []).map((l) => l.toLowerCase()));
|
|
507
|
+
output({
|
|
508
|
+
updated: true,
|
|
509
|
+
wrote: 'frontmatter',
|
|
510
|
+
preserved: preserved.filter((p) => !ownLabels.has(p.toLowerCase())),
|
|
511
|
+
}, false, undefined);
|
|
512
|
+
}
|
|
333
513
|
}
|
|
334
514
|
else {
|
|
335
|
-
output({ updated: false, reason:
|
|
515
|
+
output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
|
|
336
516
|
}
|
|
337
517
|
}
|
|
338
518
|
catch {
|
|
@@ -378,21 +558,74 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
378
558
|
sourcePath: statePath,
|
|
379
559
|
};
|
|
380
560
|
let resultData;
|
|
381
|
-
|
|
561
|
+
let precomputedUpdated = [];
|
|
562
|
+
const divergedFields = [];
|
|
563
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
564
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
565
|
+
const preWriteState = {};
|
|
566
|
+
// #3311: the milestone (phase + session) claim is consulted INSIDE the
|
|
567
|
+
// STATE.md lock, so the position read and the claim read cannot interleave
|
|
568
|
+
// with another session's Current Position write.
|
|
569
|
+
let milestoneConflict = null;
|
|
570
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
571
|
+
// advance-plan has no phase argument of its own — the phase it advances is
|
|
572
|
+
// whatever ## Current Position names. Compare that against the milestone
|
|
573
|
+
// claim: a mismatch means another session moved the single-slot position
|
|
574
|
+
// away from the claimed phase (the #3311 flip) and must be surfaced, not
|
|
575
|
+
// silently absorbed.
|
|
576
|
+
const body = stripFrontmatter(content);
|
|
577
|
+
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
578
|
+
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
579
|
+
if (positionPhase !== null) {
|
|
580
|
+
milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
|
|
581
|
+
if (milestoneConflict) {
|
|
582
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, 'state.advance-plan');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
382
585
|
const result = transitionCore(content, intent, deps);
|
|
383
586
|
resultData = result.data;
|
|
587
|
+
precomputedUpdated = result.updated;
|
|
384
588
|
return result.content;
|
|
385
|
-
}, cwd);
|
|
589
|
+
}, cwd, { divergedFields, preWriteState });
|
|
386
590
|
if (!resultData || resultData['error']) {
|
|
591
|
+
// #3807: a multi-`Phase:` Current Position section carries its own cause
|
|
592
|
+
// and its own remedy (name the candidates; the caller resolves them).
|
|
593
|
+
if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
|
|
594
|
+
output({
|
|
595
|
+
error: 'Current Position section contains more than one Phase: entry — refusing to silently advance the first. Resolve the section to a single current entry and re-run.',
|
|
596
|
+
reason: resultData['reason'],
|
|
597
|
+
phase_candidates: resultData['phase_candidates'],
|
|
598
|
+
}, raw, undefined);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
387
601
|
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
|
388
602
|
return;
|
|
389
603
|
}
|
|
604
|
+
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
|
|
605
|
+
// the bytes actually persisted — this command previously reported none of
|
|
606
|
+
// its per-field writes at all (`updated` never left `advancePlanCore`).
|
|
607
|
+
// Generalizes fix(#3351) (closes #3351's direction) and folds in any field
|
|
608
|
+
// preservation restored that this transform never touched (#3345's
|
|
609
|
+
// direction).
|
|
610
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
390
611
|
if (resultData['advanced'] === false) {
|
|
391
|
-
output(resultData, raw, 'false');
|
|
612
|
+
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
|
|
392
613
|
}
|
|
393
614
|
else {
|
|
394
|
-
output(resultData, raw, 'true');
|
|
615
|
+
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
|
|
395
616
|
}
|
|
617
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
|
|
618
|
+
// state.json `updated_at` must always mean something on disk actually
|
|
619
|
+
// moved, in EITHER branch above — so gate on `wrote`
|
|
620
|
+
// (readModifyWriteStateMd's own return value) rather than assuming both
|
|
621
|
+
// branches are unconditional mutations. They are not: re-running
|
|
622
|
+
// advance-plan on a phase already parked in its post-advance state (e.g.
|
|
623
|
+
// two same-day calls once a phase is "ready for verification") reproduces
|
|
624
|
+
// byte-identical content, the #948 no-op guard skips the write, and
|
|
625
|
+
// `resultData`/`updated` still populate normally from the transform's OWN
|
|
626
|
+
// (unwritten) output — so those are not safe publish signals here either.
|
|
627
|
+
if (wrote)
|
|
628
|
+
publishStateContract(cwd);
|
|
396
629
|
}
|
|
397
630
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
398
631
|
const statePath = planningPaths(cwd).state;
|
|
@@ -546,35 +779,126 @@ function cmdStateRecordMetric(cwd, options, raw) {
|
|
|
546
779
|
result['created'] = true;
|
|
547
780
|
output(result, raw, 'true');
|
|
548
781
|
}
|
|
782
|
+
/**
|
|
783
|
+
* #3583: computes the write-path percent AND the completed/total plan counts
|
|
784
|
+
* reported alongside it from ONE `buildStateFrontmatter` call, so
|
|
785
|
+
* `cmdStateUpdateProgress`'s JSON output cannot report a `percent` that
|
|
786
|
+
* disagrees with its own `completed`/`total` (`buildStateFrontmatter`'s
|
|
787
|
+
* `progress.{percent,completed_plans,total_plans}` all come from the same
|
|
788
|
+
* disk scan, scoped to the STORED `milestone:` frontmatter value — #3017).
|
|
789
|
+
* Re-deriving completed/total from a second, differently-scoped scan (the
|
|
790
|
+
* auto-derived one `cmdStateUpdateProgress` still runs for its own #3217/
|
|
791
|
+
* #3233 withhold gates) is what let the two disagree when the auto-derived
|
|
792
|
+
* "current" milestone differs from the stored one.
|
|
793
|
+
*
|
|
794
|
+
* Perf note: this duplicates buildStateFrontmatter's own `getMilestoneInfo`
|
|
795
|
+
* (re-reads/re-parses ROADMAP.md) and `readGitHeadSha` (a `git rev-parse`
|
|
796
|
+
* subprocess spawn) — neither is memoized, unlike the phase/plan disk scan
|
|
797
|
+
* (`_diskScanCache`), which IS shared with the second `buildStateFrontmatter`
|
|
798
|
+
* call `readModifyWriteStateMd` makes below. Both non-cached calls therefore
|
|
799
|
+
* run twice per `state update-progress`.
|
|
800
|
+
*/
|
|
801
|
+
function computeUpdateProgressPreview(statePath, cwd) {
|
|
802
|
+
const preContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
803
|
+
const existingFm = extractFrontmatter(preContent, statePath);
|
|
804
|
+
const preBody = stripFrontmatter(preContent);
|
|
805
|
+
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
806
|
+
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
807
|
+
const progress = builtFm['progress'];
|
|
808
|
+
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
|
809
|
+
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
|
810
|
+
const totalPlans = progress && typeof progress['total_plans'] === 'number' ? progress['total_plans'] : null;
|
|
811
|
+
// A null percent is REACHABLE beyond the #3217/#3233 withholds the caller
|
|
812
|
+
// already applies — buildStateFrontmatter also nulls it via its own #1761
|
|
813
|
+
// milestone-unbounded guard, evaluated from `assertedMilestoneVersion`
|
|
814
|
+
// (an independent derivation, including a bare-version-token-in-prose
|
|
815
|
+
// fallback) rather than from `storedMilestone`/diskScope, so a STATE.md
|
|
816
|
+
// with no explicit `milestone:` field but a bare vX.Y token mentioned in
|
|
817
|
+
// ROADMAP prose can pass both of the caller's guards and still land here.
|
|
818
|
+
// Falling back to a locally-computed percent would reintroduce the exact
|
|
819
|
+
// #3583 defect for that case, so withhold instead — same shape as the
|
|
820
|
+
// caller's own no-op guards.
|
|
821
|
+
if (percent === null || completedPlans === null || totalPlans === null) {
|
|
822
|
+
return { withheld: true, reason: 'progress percent withheld by buildStateFrontmatter — STATE.md left unchanged' };
|
|
823
|
+
}
|
|
824
|
+
return { withheld: false, percent, completedPlans, totalPlans };
|
|
825
|
+
}
|
|
549
826
|
function cmdStateUpdateProgress(cwd, raw) {
|
|
550
827
|
const statePath = planningPaths(cwd).state;
|
|
551
828
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
552
829
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
553
830
|
return;
|
|
554
831
|
}
|
|
555
|
-
//
|
|
832
|
+
// Auto-derived scan across current-milestone phases (outside lock — read-only).
|
|
833
|
+
// Gates the #3217/#3233 withholds below ONLY — the reported completed/total
|
|
834
|
+
// counts come from computeUpdateProgressPreview's differently-scoped
|
|
835
|
+
// (stored-milestone) scan instead, so percent and completed/total can never
|
|
836
|
+
// disagree (#3583, finding 1).
|
|
556
837
|
const phasesDir = planningPaths(cwd).phases;
|
|
557
838
|
let totalPlans = 0;
|
|
558
|
-
let
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
839
|
+
let phaseScope = SCOPE.UNREADABLE;
|
|
840
|
+
{
|
|
841
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
842
|
+
// CURRENT milestone" — routed through the canonical owner instead of a
|
|
843
|
+
// hand-rolled readdirSync + isDirInMilestone filter (which also never
|
|
844
|
+
// excluded sentinels, unlike the owner). The owner already handles an
|
|
845
|
+
// absent phasesDir as a real empty, so the fs.existsSync guard folds
|
|
846
|
+
// into it.
|
|
847
|
+
const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
848
|
+
phaseScope = scope;
|
|
564
849
|
for (const dir of phaseDirs) {
|
|
565
|
-
const { planCount
|
|
850
|
+
const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
566
851
|
totalPlans += planCount;
|
|
567
|
-
totalSummaries += summaryCount;
|
|
568
852
|
}
|
|
569
853
|
}
|
|
570
|
-
|
|
854
|
+
// #3217 (ADR-3180 §7.6 rule 4): a non-COMPLETE scope means the counts
|
|
855
|
+
// above are not a trustworthy answer — do not write a percentage derived
|
|
856
|
+
// from them into STATE.md at all (A7). This is the write path, so
|
|
857
|
+
// "withhold" means "make no edit" rather than emitting a null value.
|
|
858
|
+
if (phaseScope !== SCOPE.COMPLETE) {
|
|
859
|
+
// #3217 finding 3 (decided: surface a warning, not silent-only
|
|
860
|
+
// disclosure): the JSON `reason` field alone is easy for a caller to
|
|
861
|
+
// never read, and STATE.md's Progress field goes stale with no
|
|
862
|
+
// user-visible signal beyond it. Mirrors the established
|
|
863
|
+
// `[gsd-tools] WARNING:` stderr convention this file already uses
|
|
864
|
+
// (stateReplaceFieldWithFallback above) for a comparable silent no-op.
|
|
865
|
+
process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — phase scope is ${phaseScope}, not complete. ` +
|
|
866
|
+
`STATE.md's Progress field was left unchanged.\n`);
|
|
867
|
+
output({ updated: false, reason: `phase scope is ${phaseScope}, not complete` }, raw, 'false');
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// #3233: zero plans in the current-milestone phases means there is nothing to
|
|
871
|
+
// measure — most often the milestone was just closed and its phases archived
|
|
872
|
+
// (.planning/phases/ empty, but scope COMPLETE — "a real empty"). clampPercent
|
|
873
|
+
// maps 0/0 to 0%, which would clobber the shipped Progress record (e.g.
|
|
874
|
+
// [██████████] 100% → [░░░░░░░░░░] 0%). No-op instead, mirroring the
|
|
875
|
+
// scope-withhold above and computeProgressPercent's null-for-empty contract
|
|
876
|
+
// ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
|
|
877
|
+
// none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
|
|
878
|
+
if (totalPlans === 0) {
|
|
879
|
+
process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — no plans found in current-milestone phases (0 plans). ` +
|
|
880
|
+
`STATE.md's Progress field was left unchanged (milestone archived?).\n`);
|
|
881
|
+
output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
// #3583: percent AND the completed/total counts reported alongside it both
|
|
885
|
+
// come from the SAME buildStateFrontmatter call (computeUpdateProgressPreview)
|
|
886
|
+
// — never from the auto-derived scan above, which exists only to gate the
|
|
887
|
+
// #3217/#3233 withholds and is scoped differently (no stored-milestone
|
|
888
|
+
// override), so reusing its counts here could report a percent that
|
|
889
|
+
// disagrees with its own completed/total.
|
|
890
|
+
const preview = computeUpdateProgressPreview(statePath, cwd);
|
|
891
|
+
if (preview.withheld) {
|
|
892
|
+
process.stderr.write(`[gsd-tools] WARNING: state update-progress skipped — ${preview.reason}\n`);
|
|
893
|
+
output({ updated: false, reason: preview.reason }, raw, 'false');
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
|
|
571
897
|
const barWidth = 10;
|
|
572
898
|
const filled = Math.round(percent / 100 * barWidth);
|
|
573
899
|
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
574
900
|
const progressStr = `[${bar}] ${percent}%`;
|
|
575
901
|
let updated = false;
|
|
576
|
-
const _totalPlans = totalPlans;
|
|
577
|
-
const _totalSummaries = totalSummaries;
|
|
578
902
|
readModifyWriteStateMd(statePath, (content) => {
|
|
579
903
|
// #2177: match against the BODY only. With /i the patterns below would
|
|
580
904
|
// otherwise hit the YAML frontmatter `progress:` key first (and `\s*` would
|
|
@@ -603,7 +927,7 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
603
927
|
return fmPrefix + body.replace(pattern, (_match, prefix, value) => `${prefix}${replaceValue(value)}`);
|
|
604
928
|
}, cwd);
|
|
605
929
|
if (updated) {
|
|
606
|
-
output({ updated: true, percent, completed:
|
|
930
|
+
output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
|
|
607
931
|
}
|
|
608
932
|
else {
|
|
609
933
|
output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
|
|
@@ -630,7 +954,20 @@ function cmdStateAddDecision(cwd, options, raw) {
|
|
|
630
954
|
output({ error: 'summary required' }, raw, undefined);
|
|
631
955
|
return;
|
|
632
956
|
}
|
|
633
|
-
|
|
957
|
+
// #3231/#3481: `--phase` omitted → resolve from the STATE.md being written, via
|
|
958
|
+
// the canonical ladder `state prune` uses. A decision entry is a permanent
|
|
959
|
+
// record, so a literal `[Phase ?]` written while `current_phase` sat three
|
|
960
|
+
// lines above the insertion point loses that decision's provenance for good.
|
|
961
|
+
// Explicit `--phase` still wins, and its path is untouched — the file is not
|
|
962
|
+
// even read. When no rung resolves, `?` is still written: an unknown phase
|
|
963
|
+
// stays visibly unknown rather than being guessed or defaulted to a number.
|
|
964
|
+
let phaseId = phase;
|
|
965
|
+
if (!phaseId) {
|
|
966
|
+
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
967
|
+
const fm = extractFrontmatter(rawState, statePath);
|
|
968
|
+
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
|
|
969
|
+
}
|
|
970
|
+
const entry = `- [Phase ${phaseId || '?'}]: ${summaryText}${rationaleText ? ` — ${rationaleText}` : ''}`;
|
|
634
971
|
let _added = false;
|
|
635
972
|
let created = false;
|
|
636
973
|
readModifyWriteStateMd(statePath, (content) => {
|
|
@@ -780,7 +1117,20 @@ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
|
|
|
780
1117
|
const actionText = (action && action.trim()) || 'changed';
|
|
781
1118
|
const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
|
|
782
1119
|
const urgentText = urgent ? ' (URGENT)' : '';
|
|
783
|
-
|
|
1120
|
+
// #3481: same treatment as add-decision's #3231 fix — `--phase` omitted →
|
|
1121
|
+
// resolve from the STATE.md being written via the shared write-path ladder.
|
|
1122
|
+
// A roadmap-evolution entry is a permanent record of why the roadmap changed
|
|
1123
|
+
// shape, so a literal `Phase ?` written while `current_phase` sat in the
|
|
1124
|
+
// frontmatter above the insertion point makes that trail unattributable.
|
|
1125
|
+
// Explicit `--phase` still wins (the file is not even read on that path), and
|
|
1126
|
+
// `?` is still written when nothing resolves — never a guess.
|
|
1127
|
+
let phaseId = phase;
|
|
1128
|
+
if (!phaseId) {
|
|
1129
|
+
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
1130
|
+
const fm = extractFrontmatter(rawState, statePath);
|
|
1131
|
+
phaseId = resolveCurrentPhaseId(fm, stripFrontmatter(rawState)) ?? undefined;
|
|
1132
|
+
}
|
|
1133
|
+
const entry = `- Phase ${phaseId || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
|
|
784
1134
|
let duplicate = false;
|
|
785
1135
|
let created = false;
|
|
786
1136
|
let subsectionCreated = false;
|
|
@@ -936,6 +1286,10 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
936
1286
|
const now = clock_cjs_1.realClock.nowIso();
|
|
937
1287
|
const updated = [];
|
|
938
1288
|
let sessionCreated = false;
|
|
1289
|
+
const divergedFields = [];
|
|
1290
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
1291
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
1292
|
+
const preWriteState = {};
|
|
939
1293
|
readModifyWriteStateMd(statePath, (content) => {
|
|
940
1294
|
// Update Last session / Last Date
|
|
941
1295
|
let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
|
|
@@ -949,13 +1303,25 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
949
1303
|
updated.push('Last Date');
|
|
950
1304
|
}
|
|
951
1305
|
// Update Stopped at
|
|
1306
|
+
// #3374 Variant B: stateReplaceField returns the replaced string on any
|
|
1307
|
+
// label MATCH, including when the value is already the target. Pushing
|
|
1308
|
+
// 'Stopped At' on match alone reported a write that never changed a byte
|
|
1309
|
+
// (and that the #948 no-op guard may then discard entirely), leaving a
|
|
1310
|
+
// stale frontmatter stopped_at undetectable to the caller. Report only on
|
|
1311
|
+
// real change — and track the match separately so an identical value does
|
|
1312
|
+
// not read as "label missing" to the #944 DWIM insertion below (whose
|
|
1313
|
+
// section rewrite would reset an executor-authored resume file to None).
|
|
1314
|
+
let stoppedAtMatched = false;
|
|
952
1315
|
if (options.stopped_at) {
|
|
953
1316
|
result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped At', options.stopped_at);
|
|
954
1317
|
if (!result)
|
|
955
1318
|
result = (0, state_document_cjs_1.stateReplaceField)(content, 'Stopped at', options.stopped_at);
|
|
956
1319
|
if (result) {
|
|
957
|
-
|
|
958
|
-
|
|
1320
|
+
stoppedAtMatched = true;
|
|
1321
|
+
if (result !== content) {
|
|
1322
|
+
content = result;
|
|
1323
|
+
updated.push('Stopped At');
|
|
1324
|
+
}
|
|
959
1325
|
}
|
|
960
1326
|
}
|
|
961
1327
|
// Update Resume File — only when the caller explicitly passed a value OR the
|
|
@@ -1011,7 +1377,10 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1011
1377
|
// missing canonical fields are inserted while the heading and any prose are
|
|
1012
1378
|
// preserved (#1101). Only append a brand-new section when NEITHER heading exists.
|
|
1013
1379
|
const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
|
|
1014
|
-
|
|
1380
|
+
// #3374: keyed on the label MATCH, not on updated[] — a matched-but-
|
|
1381
|
+
// identical value is already persisted on disk and must not trigger the
|
|
1382
|
+
// insertion rewrite below.
|
|
1383
|
+
const needsStoppedAt = options.stopped_at && !stoppedAtMatched;
|
|
1015
1384
|
const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
|
|
1016
1385
|
const needsLastSession = !updated.includes('Last session') && !updated.includes('Last Date');
|
|
1017
1386
|
if (callerSuppliedValues && (needsStoppedAt || needsResumeFile || needsLastSession)) {
|
|
@@ -1135,9 +1504,14 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1135
1504
|
}
|
|
1136
1505
|
}
|
|
1137
1506
|
return content;
|
|
1138
|
-
}, cwd);
|
|
1139
|
-
|
|
1140
|
-
|
|
1507
|
+
}, cwd, { divergedFields, preWriteState });
|
|
1508
|
+
// ADR-3408 §8.4 (D4): reconcile this command's own success list against the
|
|
1509
|
+
// bytes actually persisted (fix(#3351) generalized) and fold in any field
|
|
1510
|
+
// preservation restored that this transform never touched (#3345's
|
|
1511
|
+
// direction).
|
|
1512
|
+
const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
|
|
1513
|
+
if (reconciledUpdated.length > 0) {
|
|
1514
|
+
const result = { recorded: true, updated: reconciledUpdated };
|
|
1141
1515
|
if (sessionCreated)
|
|
1142
1516
|
result['created'] = true;
|
|
1143
1517
|
output(result, raw, 'true');
|
|
@@ -1184,11 +1558,14 @@ function matchSessionSection(body) {
|
|
|
1184
1558
|
* excludes unrelated headings. Built on the same `collectSection` seam as
|
|
1185
1559
|
* matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
|
|
1186
1560
|
* Returns the section body, or null (caller falls back to full-body search).
|
|
1561
|
+
*
|
|
1562
|
+
* The scoping logic now lives in state-document.cjs's `stateCurrentPositionSlice`
|
|
1563
|
+
* (the module that owns STATE.md field extraction) — this is a thin alias kept
|
|
1564
|
+
* for call-site stability. Two copies of this scope would be exactly the kind
|
|
1565
|
+
* of generative-fix divergence the repo's parity rule exists to prevent.
|
|
1187
1566
|
*/
|
|
1188
1567
|
function matchCurrentPositionSection(body) {
|
|
1189
|
-
|
|
1190
|
-
const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, isCurrentPosition, { levelBounded: true });
|
|
1191
|
-
return section ? section.body : null;
|
|
1568
|
+
return (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
|
|
1192
1569
|
}
|
|
1193
1570
|
/**
|
|
1194
1571
|
* #2567: prevent a stale archive "Last activity:" line from overwriting a
|
|
@@ -1215,19 +1592,18 @@ function preferNewerLastActivity(existingFm, derivedFm) {
|
|
|
1215
1592
|
const derDate = derRaw.slice(0, 10);
|
|
1216
1593
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(exDate) || !/^\d{4}-\d{2}-\d{2}$/.test(derDate))
|
|
1217
1594
|
return;
|
|
1595
|
+
// #3258: this guard now protects only `last_activity` (a `derive` row) against
|
|
1596
|
+
// the stale-archive regression (#2567). `last_activity_desc` used to be
|
|
1597
|
+
// restored here too (both the older-date and the #3052 same-date branches),
|
|
1598
|
+
// but that was a date-comparison rule — a DIFFERENT policy from the
|
|
1599
|
+
// `preserve-when-unchanged` row its FIELD_CLASSIFICATION entry declares.
|
|
1600
|
+
// Keeping both was two rules that could disagree. last_activity_desc is now
|
|
1601
|
+
// governed by exactly one rule: its table row, enforced by
|
|
1602
|
+
// applyStatePreservation's #1230 delta heuristic on the RMW path (where every
|
|
1603
|
+
// desc-preserving transition — planned-phase / advance / complete / milestone
|
|
1604
|
+
// — runs). The #3052 same-date contract still holds via that delta rule.
|
|
1218
1605
|
if (derDate < exDate) {
|
|
1219
1606
|
derivedFm['last_activity'] = exRaw;
|
|
1220
|
-
if (existingFm['last_activity_desc'] !== undefined) {
|
|
1221
|
-
derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
else if (derDate === exDate) {
|
|
1225
|
-
// #3052: same-date — frontmatter is authoritative for this date, so
|
|
1226
|
-
// preserve its last_activity_desc rather than letting the derived body
|
|
1227
|
-
// prose (which may be stale) overwrite it.
|
|
1228
|
-
if (existingFm['last_activity_desc'] !== undefined) {
|
|
1229
|
-
derivedFm['last_activity_desc'] = existingFm['last_activity_desc'];
|
|
1230
|
-
}
|
|
1231
1607
|
}
|
|
1232
1608
|
}
|
|
1233
1609
|
function parseProsePhaseField(value) {
|
|
@@ -1239,6 +1615,76 @@ function parseProsePhaseField(value) {
|
|
|
1239
1615
|
// current_phase instead of clobbering it.
|
|
1240
1616
|
return parsePhaseFromProse(value);
|
|
1241
1617
|
}
|
|
1618
|
+
function resolveStatePhase(fm, body) {
|
|
1619
|
+
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
|
|
1620
|
+
const frontmatterRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', null).value;
|
|
1621
|
+
const legacyRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase').value;
|
|
1622
|
+
const currentPositionRaw = (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Phase').value;
|
|
1623
|
+
const sources = {
|
|
1624
|
+
frontmatter: parseProsePhaseField(frontmatterRaw).phase,
|
|
1625
|
+
legacy_current_phase: parseProsePhaseField(legacyRaw).phase,
|
|
1626
|
+
current_position_phase: parseProsePhaseField(currentPositionRaw).phase,
|
|
1627
|
+
};
|
|
1628
|
+
const prosePhase = parseProsePhaseField(currentPositionRaw);
|
|
1629
|
+
return {
|
|
1630
|
+
phase: sources.frontmatter ?? sources.legacy_current_phase ?? sources.current_position_phase,
|
|
1631
|
+
name: (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase_name', null).value
|
|
1632
|
+
?? (0, state_document_cjs_1.stateFieldValue)(fm, currentPositionScope, null, 'Current Phase Name').value
|
|
1633
|
+
?? prosePhase.name,
|
|
1634
|
+
sources,
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Resolve a STATE.md's own current phase id from the document itself — the
|
|
1639
|
+
* WRITE-PATH ladder shared by `cmdStateAddDecision` (#3231) and
|
|
1640
|
+
* `cmdStateAddRoadmapEvolution` (#3481), extracted from the ladder
|
|
1641
|
+
* `cmdStatePrune` already ran (#1760).
|
|
1642
|
+
*
|
|
1643
|
+
* The rungs are the canonical ones owned by state-document.cjs's
|
|
1644
|
+
* `stateFieldValue` (#3187, ADR-3180 §7.7): frontmatter `current_phase` → body
|
|
1645
|
+
* `Current Phase` field → prose `Phase: X of Y` scoped to `## Current
|
|
1646
|
+
* Position`.
|
|
1647
|
+
*
|
|
1648
|
+
* #1776: the prose rung stays scoped to `## Current Position`. Over the whole
|
|
1649
|
+
* body, `stateExtractField`'s pipe-table fallback matches any `| Phase | N |`
|
|
1650
|
+
* row — e.g. a historical verification table — and would resolve a stale phase.
|
|
1651
|
+
* Frontmatter and the explicit `Current Phase` field are unambiguous, so they
|
|
1652
|
+
* stay document-wide. `cmdStateSnapshot` deliberately keeps the looser
|
|
1653
|
+
* whole-body fallback for its own prose rung and is not routed through here.
|
|
1654
|
+
*
|
|
1655
|
+
* Returns the id exactly as written, NOT parsed to a number: phase ids are not
|
|
1656
|
+
* always integers (`11-01` and `04.1` are both real). Callers needing an
|
|
1657
|
+
* integer parse it themselves. Returns null when no rung carries a value — a
|
|
1658
|
+
* genuinely absent phase is a real answer (§7.7 behavior table row 4), and
|
|
1659
|
+
* callers must render it as unknown rather than guess one.
|
|
1660
|
+
*
|
|
1661
|
+
* NOT the same function as `resolveStatePhase` above (#3208), and deliberately
|
|
1662
|
+
* not routed through it — the difference is one line and it is the whole point:
|
|
1663
|
+
*
|
|
1664
|
+
* resolveStatePhase: matchCurrentPositionSection(body) ?? body
|
|
1665
|
+
* resolveCurrentPhaseId: null when the section is absent
|
|
1666
|
+
*
|
|
1667
|
+
* That `?? body` fallback is exactly the #1776 hazard. With no `## Current
|
|
1668
|
+
* Position` section, the prose rung widens to the entire document, where
|
|
1669
|
+
* `stateExtractField`'s pipe-table fallback matches any `| Phase | N |` row —
|
|
1670
|
+
* a historical verification table included — and resolves a stale phase.
|
|
1671
|
+
*
|
|
1672
|
+
* `resolveStatePhase`'s callers (`cmdStateSnapshot`, `cmdStateValidate`) READ
|
|
1673
|
+
* and report; a stale guess there is a wrong line in output a human is already
|
|
1674
|
+
* looking at. This function's callers WRITE: `cmdStateAddDecision` and
|
|
1675
|
+
* `cmdStateAddRoadmapEvolution` persist the result into records that outlive
|
|
1676
|
+
* the session, and `cmdStatePrune` decides what to delete from it. A wrong
|
|
1677
|
+
* phase there is durable and silent, so the write path takes the strict rung
|
|
1678
|
+
* and renders `?` rather than guessing.
|
|
1679
|
+
*
|
|
1680
|
+
* Reconcile the two only by giving `resolveStatePhase` an explicit scope
|
|
1681
|
+
* parameter — never by pointing this at it and dropping the difference.
|
|
1682
|
+
*/
|
|
1683
|
+
function resolveCurrentPhaseId(fm, body) {
|
|
1684
|
+
const positionSection = sliceCurrentPositionSection(body);
|
|
1685
|
+
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateFieldValue)(fm, positionSection, null, 'Phase').value).phase : null;
|
|
1686
|
+
return (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ?? prosePhase;
|
|
1687
|
+
}
|
|
1242
1688
|
function parseProseLastActivityField(value) {
|
|
1243
1689
|
if (!value)
|
|
1244
1690
|
return { date: null, description: null };
|
|
@@ -1264,21 +1710,9 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1264
1710
|
// reported under a content digest — STATE.md is one of the artefacts epic #1879 is about.
|
|
1265
1711
|
const fm = extractFrontmatter(content, statePath);
|
|
1266
1712
|
const body = stripFrontmatter(content);
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
// Returns null for missing, null/undefined, or empty-after-trim values so
|
|
1271
|
-
// the caller falls back to body extraction.
|
|
1272
|
-
const fmScalar = (key) => {
|
|
1273
|
-
const v = fm[key];
|
|
1274
|
-
if (v === null || v === undefined)
|
|
1275
|
-
return null;
|
|
1276
|
-
if (typeof v === 'string')
|
|
1277
|
-
return v.trim() || null;
|
|
1278
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
1279
|
-
return String(v);
|
|
1280
|
-
return null;
|
|
1281
|
-
};
|
|
1713
|
+
// #3187: frontmatter-scalar-then-body-field precedence is owned by
|
|
1714
|
+
// state-document.cjs's `stateFieldValue` (ADR-3180 §7.7) — this function no
|
|
1715
|
+
// longer holds its own fmScalar ladder.
|
|
1282
1716
|
// Extract basic fields — frontmatter keys take precedence over body
|
|
1283
1717
|
// #2956: scope `Phase` extraction to ## Current Position so a historical
|
|
1284
1718
|
// Phase: / **Phase:** line in an archive section cannot overwrite the current
|
|
@@ -1286,25 +1720,24 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1286
1720
|
// so it is scopeable exactly like Stopped At under ## Session. Fall back to
|
|
1287
1721
|
// full-body search only when no ## Current Position section exists, so files
|
|
1288
1722
|
// with no section heading keep their current behaviour.
|
|
1289
|
-
const
|
|
1290
|
-
const
|
|
1291
|
-
const
|
|
1292
|
-
const
|
|
1293
|
-
const
|
|
1294
|
-
const
|
|
1295
|
-
const
|
|
1296
|
-
const
|
|
1297
|
-
const
|
|
1298
|
-
const rawLastActivity = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
|
|
1723
|
+
const resolvedPhase = resolveStatePhase(fm, body);
|
|
1724
|
+
const currentPhase = resolvedPhase.phase;
|
|
1725
|
+
const currentPhaseName = resolvedPhase.name;
|
|
1726
|
+
const totalPhasesRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_phases', 'Total Phases').value;
|
|
1727
|
+
const currentPlan = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_plan', 'Current Plan').value;
|
|
1728
|
+
const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
|
|
1729
|
+
const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value;
|
|
1730
|
+
const progressRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'progress', 'Progress').value;
|
|
1731
|
+
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;
|
|
1299
1732
|
const proseLastActivity = parseProseLastActivityField(rawLastActivity);
|
|
1300
|
-
const lastActivity =
|
|
1301
|
-
const lastActivityDesc =
|
|
1733
|
+
const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', null).value ?? proseLastActivity.date ?? rawLastActivity;
|
|
1734
|
+
const lastActivityDesc = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity_desc', 'Last Activity Description').value ?? proseLastActivity.description;
|
|
1302
1735
|
// #2956: Paused At canonically lives in ## Session (see the comment above
|
|
1303
1736
|
// preferNewerLastActivity and the write seam in buildStateFrontmatter). The
|
|
1304
1737
|
// write seam already scopes it to ## Session; this read seam must agree, so a
|
|
1305
1738
|
// stale "Paused At:" in a Session Continuity Archive cannot win here either.
|
|
1306
1739
|
const sessionScope = matchSessionSection(body) ?? body;
|
|
1307
|
-
const pausedAt =
|
|
1740
|
+
const pausedAt = (0, state_document_cjs_1.stateFieldValue)(fm, sessionScope, 'paused_at', 'Paused At').value;
|
|
1308
1741
|
// Parse numeric fields
|
|
1309
1742
|
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
1310
1743
|
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
@@ -1430,7 +1863,7 @@ function extractRetiredPhaseNumbers(scope) {
|
|
|
1430
1863
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
1431
1864
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1432
1865
|
*/
|
|
1433
|
-
function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
1866
|
+
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases) {
|
|
1434
1867
|
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
|
1435
1868
|
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
|
1436
1869
|
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
|
@@ -1464,14 +1897,46 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1464
1897
|
const pausedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Paused At');
|
|
1465
1898
|
let milestone = null;
|
|
1466
1899
|
let milestoneName = null;
|
|
1900
|
+
// #1761 regression fix (#3216): the milestone STATE.md actually ASSERTS,
|
|
1901
|
+
// independent of whether getMilestoneInfo's identity scope is COMPLETE.
|
|
1902
|
+
// Needed below by the disk-scan block's `isMilestoneBoundedInRoadmap` guard
|
|
1903
|
+
// — that check answers "is the ASSERTED version bounded to a versioned
|
|
1904
|
+
// ROADMAP heading", a different question from "is the identity trustworthy
|
|
1905
|
+
// enough to persist" (`milestone` above). Conflating the two regressed
|
|
1906
|
+
// #1761: when a real STATE `milestone:` value has no matching ROADMAP
|
|
1907
|
+
// heading, `info.scope` is never COMPLETE (rightly — there's no curated
|
|
1908
|
+
// name to persist), but the version was still genuinely asserted and the
|
|
1909
|
+
// bounded check must still run on it, or the guard silently no-ops and
|
|
1910
|
+
// `state json` reports a conflated whole-document total_phases/percent.
|
|
1911
|
+
let assertedMilestoneVersion = null;
|
|
1467
1912
|
if (cwd) {
|
|
1468
1913
|
// DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
|
|
1469
1914
|
// try/catch (roadmap-parser.cts) that already swallows every internal
|
|
1470
|
-
// failure and always returns a
|
|
1915
|
+
// failure and always returns a ScopedResult — it never throws, so this
|
|
1471
1916
|
// wrapper could never be triggered.
|
|
1917
|
+
// #3216 (ADR-3180 §7.2 rule 6): this is the #3197 disk-write path. Rule 6
|
|
1918
|
+
// draws the line at the FIELD, not the scope as a whole — "a version known
|
|
1919
|
+
// but no name resolvable is TRUNCATED carrying {version, name: null} — the
|
|
1920
|
+
// version is a real answer, the name is a non-answer, and collapsing the
|
|
1921
|
+
// two is the failure this contract exists to prevent." So `milestone`
|
|
1922
|
+
// (the version) is written whenever COMPLETE or TRUNCATED — both carry a
|
|
1923
|
+
// genuine version per rule 6 — while `milestoneName` is written only on
|
|
1924
|
+
// COMPLETE, since TRUNCATED's name is by definition unresolved and must
|
|
1925
|
+
// never be fabricated. UNSCOPED/UNREADABLE have no real version either
|
|
1926
|
+
// way, so both stay null there. This mirrors cmdCommit (src/commands.cts),
|
|
1927
|
+
// which accepts COMPLETE or TRUNCATED for the same reason (the version is
|
|
1928
|
+
// real), and deliberately diverges from archivePhaseDirectories
|
|
1929
|
+
// (src/milestone.cts), which demands COMPLETE only because it uses the
|
|
1930
|
+
// value as a filesystem path component and a TRUNCATED version is not
|
|
1931
|
+
// safe to use there.
|
|
1472
1932
|
const info = getMilestoneInfo(cwd);
|
|
1473
|
-
|
|
1474
|
-
|
|
1933
|
+
assertedMilestoneVersion = info.value ? info.value.version : null;
|
|
1934
|
+
if ((info.scope === SCOPE.COMPLETE || info.scope === SCOPE.TRUNCATED) && info.value) {
|
|
1935
|
+
milestone = info.value.version;
|
|
1936
|
+
}
|
|
1937
|
+
if (info.scope === SCOPE.COMPLETE && info.value) {
|
|
1938
|
+
milestoneName = info.value.name;
|
|
1939
|
+
}
|
|
1475
1940
|
}
|
|
1476
1941
|
let totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
|
1477
1942
|
let completedPhases = null;
|
|
@@ -1480,6 +1945,14 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1480
1945
|
// #1761 read-path: set from cached.milestoneBounded inside the disk-scan
|
|
1481
1946
|
// block; consumed at the percent computation to mirror the cmdStateSync guard.
|
|
1482
1947
|
let milestoneUnbounded = false;
|
|
1948
|
+
// #3217 (ADR-3180 §7.6 rule 4, finding 1): the real listMilestonePhaseDirs
|
|
1949
|
+
// scope for the disk-scanned counts below, set from cached.phaseDirScope
|
|
1950
|
+
// when a fresh disk scan runs. SCOPE.COMPLETE is the correct default here
|
|
1951
|
+
// — NOT a rule-4 hardcode — for the cases where no disk scan happens at all
|
|
1952
|
+
// (no cwd, or phasesDir absent): totalPhases/totalPlans then come straight
|
|
1953
|
+
// from the pre-existing frontmatter fields parsed above, a path this phase
|
|
1954
|
+
// does not touch and which predates listMilestonePhaseDirs entirely.
|
|
1955
|
+
let diskScope = SCOPE.COMPLETE;
|
|
1483
1956
|
if (cwd) {
|
|
1484
1957
|
try {
|
|
1485
1958
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -1507,14 +1980,16 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1507
1980
|
// #3017: scope the milestone filter to the STORED milestone when available,
|
|
1508
1981
|
// so a state.* write doesn't auto-derive (and mis-bind) to a different
|
|
1509
1982
|
// milestone's heading and clobber the stored value + progress counts.
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1983
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
1984
|
+
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
1985
|
+
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
1986
|
+
// (which also never excluded sentinels, unlike the owner).
|
|
1987
|
+
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null });
|
|
1514
1988
|
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1515
1989
|
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1516
|
-
// de-duplicate by normalized phase number keeping
|
|
1517
|
-
//
|
|
1990
|
+
// de-duplicate by normalized phase number keeping exactly one dir
|
|
1991
|
+
// per key (deterministic tie-break: see #3355 below). This prevents
|
|
1992
|
+
// double-counting (e.g. two "Phase 1" dirs).
|
|
1518
1993
|
const seenPhaseNums = new Map(); // normalizedNum -> dirName
|
|
1519
1994
|
for (const dir of allMatchingDirs) {
|
|
1520
1995
|
// #1514: a retired/folded phase keeps a directory but no completion
|
|
@@ -1523,22 +1998,35 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1523
1998
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
1524
1999
|
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
|
|
1525
2000
|
continue;
|
|
1526
|
-
//
|
|
1527
|
-
|
|
1528
|
-
|
|
2001
|
+
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
2002
|
+
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
2003
|
+
// diverged from extractPhaseToken/phaseKeyFromDir on
|
|
2004
|
+
// project-code-prefixed dirs (whole dirname fell through as the key,
|
|
2005
|
+
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
2006
|
+
// multi-segment milestone dirs. Same key surface used two lines
|
|
2007
|
+
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
2008
|
+
const key = phaseKeyFromDir(dir);
|
|
1529
2009
|
if (!seenPhaseNums.has(key)) {
|
|
1530
2010
|
seenPhaseNums.set(key, dir);
|
|
1531
2011
|
}
|
|
1532
2012
|
else {
|
|
1533
|
-
//
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
2013
|
+
// #3355: the survivor of a same-milestone collision must be
|
|
2014
|
+
// chosen from repository CONTENT, never from filesystem state.
|
|
2015
|
+
// The pre-#3355 tie-break was `mtimeMs` — a checkout-order
|
|
2016
|
+
// signal — so two byte-identical checkouts of the same commit
|
|
2017
|
+
// that wrote the colliding dirs in a different order picked
|
|
2018
|
+
// different survivors, and progress.total_plans /
|
|
2019
|
+
// completed_plans drifted across clones and CI runs. The
|
|
2020
|
+
// directory NAME is git-tracked content and a total order, so
|
|
2021
|
+
// the lexicographically-first dir wins deterministically. The
|
|
2022
|
+
// collision is still a project-level defect (duplicate phase
|
|
2023
|
+
// number in scope), so it is surfaced on stderr instead of
|
|
2024
|
+
// being silently resolved. The Bug #2445 invariant — exactly
|
|
2025
|
+
// one survivor per normalized phase number — is unchanged.
|
|
2026
|
+
const incumbent = seenPhaseNums.get(key);
|
|
2027
|
+
const survivor = dir < incumbent ? dir : incumbent;
|
|
2028
|
+
seenPhaseNums.set(key, survivor);
|
|
2029
|
+
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`);
|
|
1542
2030
|
}
|
|
1543
2031
|
}
|
|
1544
2032
|
const phaseDirs = [...seenPhaseNums.values()];
|
|
@@ -1547,10 +2035,18 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1547
2035
|
let diskCompletedPhases = 0;
|
|
1548
2036
|
for (const dir of phaseDirs) {
|
|
1549
2037
|
const phaseDir = node_path_1.default.join(phasesDir, dir);
|
|
1550
|
-
const { planCount, summaryCount
|
|
2038
|
+
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
|
|
1551
2039
|
diskTotalPlans += planCount;
|
|
1552
2040
|
diskTotalSummaries += summaryCount;
|
|
1553
|
-
|
|
2041
|
+
// ADR-3180 §7.4 (#3186, #2957 disk-strict): "which phases are
|
|
2042
|
+
// complete" is the completion question, routed through the single
|
|
2043
|
+
// canonical owner (isPhaseComplete, src/verification.cts) — NOT
|
|
2044
|
+
// scanPhasePlans's own `completed` field, which only answers "are
|
|
2045
|
+
// all plans summarized" (a different question; see plan-scan.cts's
|
|
2046
|
+
// own comment on that field). Folding this consumer onto the raw
|
|
2047
|
+
// summaries-met flag was the exact "consolidate two of three and
|
|
2048
|
+
// leave the third" gap §7.4's forcing function rules out.
|
|
2049
|
+
if (isPhaseComplete(phaseDir).value.complete)
|
|
1554
2050
|
diskCompletedPhases++;
|
|
1555
2051
|
}
|
|
1556
2052
|
// Count phase headings from ROADMAP using a digit-containing pattern
|
|
@@ -1567,8 +2063,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1567
2063
|
// Only count tokens that contain at least one digit — excludes
|
|
1568
2064
|
// pure-word section headings (Overview, Details) while keeping
|
|
1569
2065
|
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
1570
|
-
// Also exclude
|
|
1571
|
-
|
|
2066
|
+
// Also exclude sentinel phases (0 and 999.x backlog).
|
|
2067
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2068
|
+
if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
|
|
1572
2069
|
continue;
|
|
1573
2070
|
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
1574
2071
|
// exclude them from the denominator (they can never be completed).
|
|
@@ -1586,9 +2083,22 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1586
2083
|
// phase-dir count only, and mark unbounded so percent is skipped
|
|
1587
2084
|
// downstream (mirrors the sync write-path guard).
|
|
1588
2085
|
let milestoneBounded = true;
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
2086
|
+
// #3216 fix (#1761 regression): use `assertedMilestoneVersion` —
|
|
2087
|
+
// the version STATE.md actually asserts — not the scope-gated
|
|
2088
|
+
// `milestone`. `milestone` is null on any non-COMPLETE identity
|
|
2089
|
+
// scope (deliberately, so a non-trustworthy identity never
|
|
2090
|
+
// persists), but a real asserted version with no matching
|
|
2091
|
+
// ROADMAP heading is EXACTLY the unbounded case this guard exists
|
|
2092
|
+
// to catch; gating on `milestone` skipped the guard entirely and
|
|
2093
|
+
// let the whole-document roadmapPhaseCount conflate sibling
|
|
2094
|
+
// milestones again.
|
|
2095
|
+
if (assertedMilestoneVersion && roadmapRaw !== null) {
|
|
2096
|
+
// #3184: routed through the single owner (roadmap-parser.cjs)
|
|
2097
|
+
// instead of a hand-rolled, unbounded-substring re-derivation —
|
|
2098
|
+
// the prior inline regex had no boundary assertion after the
|
|
2099
|
+
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
|
|
2100
|
+
// defect, design row 17).
|
|
2101
|
+
milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
|
|
1592
2102
|
}
|
|
1593
2103
|
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
|
|
1594
2104
|
// at all — only Phase headings) from a MILESTONED-but-unbounded one
|
|
@@ -1596,27 +2106,94 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1596
2106
|
// On a flat roadmap the whole-doc count is correct (no sibling milestones to
|
|
1597
2107
|
// conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
|
|
1598
2108
|
// so fall back to phaseDirs.length.
|
|
1599
|
-
|
|
1600
|
-
|
|
2109
|
+
// #3184: routed through the single owner (roadmap-parser.cjs) —
|
|
2110
|
+
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
|
|
2111
|
+
// version-token requirement); see hasMilestoneSectioning's own
|
|
2112
|
+
// doc comment for why that distinction is load-bearing.
|
|
2113
|
+
// #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
|
|
2114
|
+
// not the >=2 predicate. >=2 under-answers the question this branch
|
|
2115
|
+
// asks: with EXACTLY ONE milestone section and an asserted milestone
|
|
2116
|
+
// absent from the ROADMAP, >=2 read "flat" and the whole-document
|
|
2117
|
+
// count — which IS that single section's phases — was written as the
|
|
2118
|
+
// asserted milestone's total, silently clobbering the stored value.
|
|
2119
|
+
// The >=2 threshold governs SIBLING conflation; asserted-vs-section
|
|
2120
|
+
// needs only one section to go wrong. Zero sections (genuinely flat)
|
|
2121
|
+
// keeps the whole-document count, per #2828.
|
|
2122
|
+
const roadmapHasAnyMilestoneSection = roadmapRaw !== null
|
|
2123
|
+
&& hasAnyMilestoneSection(roadmapRaw);
|
|
1601
2124
|
const safeToUseRoadmapCount = milestoneBounded
|
|
1602
|
-
|| (roadmapPhaseCount > 0 && !
|
|
2125
|
+
|| (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
|
|
2126
|
+
// #3354: the milestoned-but-unbounded sibling of the #2828/#3204
|
|
2127
|
+
// shapes. The whole-document roadmapPhaseCount is rightly rejected
|
|
2128
|
+
// above (it would conflate sibling milestones, #1761), but the
|
|
2129
|
+
// on-disk phase-dir count is NOT an authoritative substitute for
|
|
2130
|
+
// the rejected total either — it counts only the current
|
|
2131
|
+
// milestone's realized directories (25 declared → 4 written in the
|
|
2132
|
+
// issue's report), silently shrinking progress.total_phases on
|
|
2133
|
+
// every STATE.md write. Mirror the branch's own percent withhold
|
|
2134
|
+
// (milestoneUnbounded below): return a null sentinel so the caller
|
|
2135
|
+
// keeps the pre-existing stored value instead of writing the
|
|
2136
|
+
// substitute, and warn on stderr naming the unbounded token so the
|
|
2137
|
+
// operator can curate the ROADMAP heading or the STATE assertion.
|
|
2138
|
+
// The degenerate un-sectioned zero-heading case keeps the
|
|
2139
|
+
// phaseDirs.length fallback — with nothing declared anywhere else,
|
|
2140
|
+
// the disk count is the only source and remains correct.
|
|
2141
|
+
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
|
|
2142
|
+
if (milestonedButUnbounded) {
|
|
2143
|
+
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so progress.total_phases is left at its stored value. (#3354/#3642)\n`);
|
|
2144
|
+
}
|
|
2145
|
+
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
|
2146
|
+
// absent/unreadable the #549 heading counter never ran (roadmapScope
|
|
2147
|
+
// stayed null), `milestoneBounded` is vacuously true (its gate requires
|
|
2148
|
+
// roadmapRaw), and the dir count — which only ever counts phases that
|
|
2149
|
+
// have STARTED — would be persisted as progress.total_phases by every
|
|
2150
|
+
// state.* write. A STATE that asserts a milestone (storedMilestone —
|
|
2151
|
+
// getMilestoneInfo is useless here, it reads the roadmap that is
|
|
2152
|
+
// absent) declared a total somewhere; keep the stored frontmatter
|
|
2153
|
+
// value instead. Without an asserted milestone (fresh project,
|
|
2154
|
+
// pre-roadmap) the disk count is still the only source and stays
|
|
2155
|
+
// authoritative (the #3354 doctrine's degenerate case).
|
|
2156
|
+
const roadmapAbsentWithAssertedMilestone = roadmapRaw === null &&
|
|
2157
|
+
typeof storedMilestone === 'string' &&
|
|
2158
|
+
storedMilestone.trim() !== '';
|
|
2159
|
+
if (roadmapAbsentWithAssertedMilestone) {
|
|
2160
|
+
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`);
|
|
2161
|
+
}
|
|
1603
2162
|
return {
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
2163
|
+
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
|
2164
|
+
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
|
2165
|
+
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
|
|
2166
|
+
// vacuously true (its gate requires roadmapRaw), so the safe-count
|
|
2167
|
+
// arm would otherwise swallow the withhold.
|
|
2168
|
+
totalPhases: (milestonedButUnbounded || roadmapAbsentWithAssertedMilestone)
|
|
2169
|
+
? null
|
|
2170
|
+
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
|
1607
2171
|
milestoneBounded,
|
|
1608
2172
|
completedPhases: diskCompletedPhases,
|
|
1609
2173
|
totalPlans: diskTotalPlans,
|
|
1610
2174
|
completedPlans: diskTotalSummaries,
|
|
2175
|
+
phaseDirScope,
|
|
1611
2176
|
};
|
|
1612
2177
|
})();
|
|
1613
2178
|
_diskScanCache.set(cwd, cached);
|
|
1614
2179
|
}
|
|
1615
|
-
|
|
2180
|
+
// #3354: cached.totalPhases === null is the milestoned-but-unbounded
|
|
2181
|
+
// WITHHOLD sentinel — the scan refused to substitute the dir count for
|
|
2182
|
+
// a rejected whole-document total, so keep the pre-existing value:
|
|
2183
|
+
// the stored frontmatter total when the caller can supply it, else the
|
|
2184
|
+
// body "Total Phases" annotation already parsed above, else leave null
|
|
2185
|
+
// (the key is omitted from the progress block).
|
|
2186
|
+
if (cached.totalPhases !== null) {
|
|
2187
|
+
totalPhases = cached.totalPhases;
|
|
2188
|
+
}
|
|
2189
|
+
else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
|
|
2190
|
+
totalPhases = storedTotalPhases;
|
|
2191
|
+
}
|
|
1616
2192
|
completedPhases = cached.completedPhases;
|
|
1617
2193
|
totalPlans = cached.totalPlans;
|
|
1618
2194
|
completedPlans = cached.completedPlans;
|
|
1619
2195
|
milestoneUnbounded = cached.milestoneBounded === false;
|
|
2196
|
+
diskScope = cached.phaseDirScope;
|
|
1620
2197
|
}
|
|
1621
2198
|
/* best-effort (#2245 audit): this is a READ path building STATE.md's
|
|
1622
2199
|
* display frontmatter. The real throw source is fs.readdirSync(phasesDir)
|
|
@@ -1633,17 +2210,66 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1633
2210
|
// ROADMAP-declared-but-unrealized future phases cap the reported completion
|
|
1634
2211
|
// instead of a false 100% from plan-only coverage (#3242 Bug B).
|
|
1635
2212
|
// Falls back to the body Progress: field only when no plan files exist on disk.
|
|
1636
|
-
|
|
2213
|
+
// #3217 (ADR-3180 §7.6 rule 4, finding 1): computeProgressPercent requires
|
|
2214
|
+
// a `Scope` for its own rule-4 gate. `diskScope` is the real
|
|
2215
|
+
// `listMilestonePhaseDirs` scope threaded through `_diskScanCache`
|
|
2216
|
+
// (`phaseDirScope` above) when a fresh disk scan ran — an UNREADABLE
|
|
2217
|
+
// phases dir now withholds here exactly as it does at every sibling
|
|
2218
|
+
// surface, closing the cross-surface disagreement the isolated review
|
|
2219
|
+
// caught. When no disk scan ran at all (no cwd, or phasesDir absent)
|
|
2220
|
+
// `diskScope` keeps its SCOPE.COMPLETE default, preserving this
|
|
2221
|
+
// function's pre-existing behavior on that (unrelated, pre-dating
|
|
2222
|
+
// listMilestonePhaseDirs) fallback path. This call site also keeps its own
|
|
2223
|
+
// orthogonal `milestoneUnbounded` null-out below (#1761) — a different
|
|
2224
|
+
// guard (ROADMAP heading boundedness, not disk readability).
|
|
2225
|
+
let progressPercent = (0, state_document_cjs_1.computeProgressPercent)(completedPlans, totalPlans, completedPhases, totalPhases, diskScope);
|
|
1637
2226
|
// #1761 read-path: when the milestone can't be bounded, percent would be
|
|
1638
2227
|
// derived from a conflated/understated total — skip it (mirror cmdStateSync).
|
|
1639
2228
|
if (milestoneUnbounded)
|
|
1640
2229
|
progressPercent = null;
|
|
1641
|
-
|
|
2230
|
+
// #3217 finding 1 (follow-on): a non-COMPLETE diskScope must withhold the
|
|
2231
|
+
// percentage EVERYWHERE, including this prose fallback — without the
|
|
2232
|
+
// `diskScope === SCOPE.COMPLETE` guard, a stale/existing "Progress: N%"
|
|
2233
|
+
// body line would silently defeat computeProgressPercent's rule-4 null,
|
|
2234
|
+
// re-introducing a rendered percentage on the exact scope this phase
|
|
2235
|
+
// withholds for (this is how the reviewer's UNREADABLE-phases fixture
|
|
2236
|
+
// could still surface a number even after the scope threading above).
|
|
2237
|
+
if (progressPercent === null && progressRaw && !milestoneUnbounded && diskScope === SCOPE.COMPLETE) {
|
|
1642
2238
|
const pctMatch = progressRaw.match(/(\d+)%/);
|
|
1643
2239
|
if (pctMatch)
|
|
1644
2240
|
progressPercent = parseInt(pctMatch[1], 10);
|
|
1645
2241
|
}
|
|
1646
|
-
|
|
2242
|
+
let normalizedStatus = (0, state_document_cjs_1.normalizeStateStatus)(status, pausedAt);
|
|
2243
|
+
// #3578: normalizeStateStatus matches 'complete' as a case-insensitive
|
|
2244
|
+
// SUBSTRING, so the phase-completion prose cmdStateCompletePhase writes to
|
|
2245
|
+
// the body (`Phase ${N} complete`) collapses to the milestone-level
|
|
2246
|
+
// 'completed' status even when other phases remain open. Phase-level
|
|
2247
|
+
// prose must never decide milestone-level status — completedPhases /
|
|
2248
|
+
// totalPhases / diskScope, already derived above from a disk scan, are
|
|
2249
|
+
// the authority on whether the MILESTONE is actually done. Only override
|
|
2250
|
+
// when: (a) normalizeStateStatus actually landed on 'completed'; (b) the
|
|
2251
|
+
// raw prose is UNAMBIGUOUSLY phase-completion prose — the anchored
|
|
2252
|
+
// pattern below deliberately excludes "All phases complete" (no `\S+`
|
|
2253
|
+
// phase token) and milestone-close prose like "v1.0 milestone complete"
|
|
2254
|
+
// (no leading "phase"); and (c) the counters are trustworthy (a COMPLETE
|
|
2255
|
+
// disk scope, both counts are finite numbers, and a positive
|
|
2256
|
+
// denominator) and affirmatively disagree with 'completed'. In every
|
|
2257
|
+
// other case normalizedStatus is left exactly as normalizeStateStatus
|
|
2258
|
+
// returned it.
|
|
2259
|
+
if (normalizedStatus === 'completed' &&
|
|
2260
|
+
typeof status === 'string' &&
|
|
2261
|
+
/^\s*phase\s+\S+\s+complete\s*$/i.test(status) &&
|
|
2262
|
+
diskScope === SCOPE.COMPLETE &&
|
|
2263
|
+
// #1761: an unbounded milestone yields a conflated/understated total — the
|
|
2264
|
+
// same authority that nulls progressPercent above. Without this, a bad
|
|
2265
|
+
// denominator could demote a genuinely-complete milestone.
|
|
2266
|
+
!milestoneUnbounded &&
|
|
2267
|
+
typeof completedPhases === 'number' && Number.isFinite(completedPhases) &&
|
|
2268
|
+
typeof totalPhases === 'number' && Number.isFinite(totalPhases) &&
|
|
2269
|
+
totalPhases > 0 &&
|
|
2270
|
+
completedPhases < totalPhases) {
|
|
2271
|
+
normalizedStatus = 'executing';
|
|
2272
|
+
}
|
|
1647
2273
|
const fm = { gsd_state_version: '1.0' };
|
|
1648
2274
|
if (milestone)
|
|
1649
2275
|
fm['milestone'] = milestone;
|
|
@@ -1665,6 +2291,13 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1665
2291
|
fm['last_activity'] = lastActivity;
|
|
1666
2292
|
if (lastActivityDesc)
|
|
1667
2293
|
fm['last_activity_desc'] = lastActivityDesc;
|
|
2294
|
+
// #2573: stamp the commit this STATE.md was written against, so consumers can
|
|
2295
|
+
// report how far the codebase has moved since. Omitted entirely outside a git
|
|
2296
|
+
// repo — an absent field reads as "unknown", which is the honest answer and
|
|
2297
|
+
// keeps every consumer's tri-state intact (see readStateHeadFreshness).
|
|
2298
|
+
const stateHead = readGitHeadSha(cwd);
|
|
2299
|
+
if (stateHead)
|
|
2300
|
+
fm['state_head'] = stateHead;
|
|
1668
2301
|
const progress = {};
|
|
1669
2302
|
if (totalPhases !== null)
|
|
1670
2303
|
progress['total_phases'] = totalPhases;
|
|
@@ -1680,18 +2313,227 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone) {
|
|
|
1680
2313
|
fm['progress'] = progress;
|
|
1681
2314
|
return fm;
|
|
1682
2315
|
}
|
|
1683
|
-
|
|
2316
|
+
// ─── state_head commit provenance (#2573) ────────────────────────────────────
|
|
2317
|
+
//
|
|
2318
|
+
// STATE.md records the commit it was written against (`state_head`); consumers
|
|
2319
|
+
// derive how many commits the codebase has moved since. This mirrors the shipped
|
|
2320
|
+
// graphify commit-staleness contract (src/graphify.cts, #3170) rather than
|
|
2321
|
+
// inventing a second vocabulary: `commits_behind` is a count, and `commit_stale`
|
|
2322
|
+
// is TRI-STATE — null means "we don't know" (no git, no stamp, unresolvable
|
|
2323
|
+
// commit), which is deliberately distinct from false ("known fresh").
|
|
2324
|
+
//
|
|
2325
|
+
// IMPORTANT — this is a freshness PROXY, never a drift measurement.
|
|
2326
|
+
// `rev-list state_head..HEAD` counts every commit in between, including ones
|
|
2327
|
+
// that never touched anything STATE.md describes. And because `state_head`
|
|
2328
|
+
// restamps on EVERY state write, a low count means "something wrote STATE
|
|
2329
|
+
// recently", NOT "STATE's content is accurate". Consumers must word it as
|
|
2330
|
+
// approximate and must never gate on it.
|
|
2331
|
+
/** Strict hash fence before any value from disk reaches a git argument. */
|
|
2332
|
+
const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
|
|
2333
|
+
/**
|
|
2334
|
+
* Resolve the project's current HEAD sha, or null when unavailable.
|
|
2335
|
+
* Bounded + non-interactive via execGit (10s timeout, GIT_TERMINAL_PROMPT=0);
|
|
2336
|
+
* a non-repo, missing git, or timeout degrades to null rather than throwing.
|
|
2337
|
+
*/
|
|
2338
|
+
/**
|
|
2339
|
+
* Does the project root carry its own git repository?
|
|
2340
|
+
*
|
|
2341
|
+
* #2573 D5. `git rev-parse HEAD` walks UP from cwd and stops at the FIRST
|
|
2342
|
+
* enclosing `.git`. So the repo that answered is the project's own exactly when
|
|
2343
|
+
* the project root itself carries a `.git` entry — a directory for a normal
|
|
2344
|
+
* clone, a file for a worktree or submodule, both of which `existsSync` accepts.
|
|
2345
|
+
* If it does not, the answer necessarily came from an ancestor repo and the
|
|
2346
|
+
* stamp would assert provenance the project cannot claim.
|
|
2347
|
+
*
|
|
2348
|
+
* Deliberately a filesystem-identity check rather than comparing
|
|
2349
|
+
* `--show-toplevel` against the project root as strings. That comparison is
|
|
2350
|
+
* unreliable across platforms — macOS resolves temp dirs through
|
|
2351
|
+
* `/private/var/…`, Windows adds 8.3 short names and separator/case variance —
|
|
2352
|
+
* and an over-strict compare degrades healthy projects to "unknown", which is
|
|
2353
|
+
* the very failure this check exists to prevent, inverted. No path spelling is
|
|
2354
|
+
* involved here at all.
|
|
2355
|
+
*/
|
|
2356
|
+
function projectOwnsItsRepo(projectRoot) {
|
|
2357
|
+
try {
|
|
2358
|
+
return node_fs_1.default.existsSync(node_path_1.default.join(projectRoot, '.git'));
|
|
2359
|
+
}
|
|
2360
|
+
catch {
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
function readGitHeadSha(cwd) {
|
|
2365
|
+
if (!cwd)
|
|
2366
|
+
return null;
|
|
2367
|
+
// #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the nearest
|
|
2368
|
+
// enclosing `.git`, and nothing pins that repo to the project. A GSD project
|
|
2369
|
+
// living inside an unrelated checkout — a dotfiles/notes repo, or the outer
|
|
2370
|
+
// workspace of a `planning.sub_repos` layout where all code commits land in
|
|
2371
|
+
// the sub-repos — would otherwise measure freshness against a repo it has no
|
|
2372
|
+
// relationship to, and report `commit_stale: false` ("known fresh") while
|
|
2373
|
+
// doing it. Unverified provenance must degrade to unknown, never to fresh.
|
|
2374
|
+
//
|
|
2375
|
+
// TWO independent conditions must hold before a stamp is trustworthy, and both
|
|
2376
|
+
// are checked below because either alone is insufficient:
|
|
2377
|
+
// 1. the project root owns a `.git` (else an ancestor repo answered), and
|
|
2378
|
+
// 2. the project is not a `sub_repos` workspace (else the repo that answers
|
|
2379
|
+
// is the outer wrapper, whose HEAD does not move when the code does).
|
|
2380
|
+
// KNOWN LIMITATION, by design: in a `sub_repos` workspace this feature reports
|
|
2381
|
+
// unknown rather than measuring the children. Per-child freshness needs a
|
|
2382
|
+
// defined aggregate across N histories and is out of scope for this increment.
|
|
2383
|
+
//
|
|
2384
|
+
// `--show-toplevel HEAD` answers both in ONE spawn, so pinning costs no extra
|
|
2385
|
+
// subprocess on this path (the caller holds the STATE lock).
|
|
2386
|
+
let projectRoot;
|
|
2387
|
+
try {
|
|
2388
|
+
projectRoot = (0, project_root_cjs_1.findProjectRoot)(cwd);
|
|
2389
|
+
}
|
|
2390
|
+
catch {
|
|
2391
|
+
return null; // cannot prove which repo would answer → unknown
|
|
2392
|
+
}
|
|
2393
|
+
if (!projectOwnsItsRepo(projectRoot))
|
|
2394
|
+
return null;
|
|
2395
|
+
// #2573 D5, sub_repos flavor. Owning a `.git` is necessary but NOT sufficient.
|
|
2396
|
+
// In a `planning.sub_repos` workspace the outer directory can legitimately own
|
|
2397
|
+
// BOTH `.planning/` and its own repo while every code commit lands in a nested
|
|
2398
|
+
// child repo — `docs/CONFIGURATION.md` describes sub_repos as scoping work per
|
|
2399
|
+
// sub-repo "instead of treating the outer repo as a monorepo". The outer HEAD
|
|
2400
|
+
// then never advances, so `merge-base --is-ancestor` passes trivially and
|
|
2401
|
+
// `rev-list` counts 0: the stamp would report `commit_stale: false`, i.e.
|
|
2402
|
+
// "known fresh", while the code it describes has moved arbitrarily far.
|
|
2403
|
+
//
|
|
2404
|
+
// That is a WRONG answer, not a missing one, and it is the same invariant the
|
|
2405
|
+
// ancestor-repo check above exists to protect: a freshness claim the project
|
|
2406
|
+
// cannot substantiate must degrade to unknown, never to fresh. Measuring the
|
|
2407
|
+
// children instead would mean picking one HEAD out of N unrelated histories
|
|
2408
|
+
// (or inventing an aggregate), which is a design question beyond this
|
|
2409
|
+
// increment — so this scopes to the honest tri-state and declines to answer.
|
|
2410
|
+
// Deliberately keyed on the DECLARED config rather than probing the filesystem
|
|
2411
|
+
// for nested `.git` entries: the declaration is what the workspace asserts
|
|
2412
|
+
// about itself, and a probe would spuriously fire on a vendored dependency.
|
|
2413
|
+
try {
|
|
2414
|
+
const subRepos = loadConfig(projectRoot).sub_repos;
|
|
2415
|
+
if (Array.isArray(subRepos) && subRepos.length > 0)
|
|
2416
|
+
return null;
|
|
2417
|
+
}
|
|
2418
|
+
catch {
|
|
2419
|
+
return null; // cannot read the layout → cannot claim provenance → unknown
|
|
2420
|
+
}
|
|
2421
|
+
const r = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', 'HEAD'], { cwd });
|
|
2422
|
+
if (r.exitCode !== 0)
|
|
2423
|
+
return null;
|
|
2424
|
+
const sha = r.stdout.trim();
|
|
2425
|
+
return STATE_HEAD_HASH_RE.test(sha) ? sha : null;
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Derive the commit-age freshness signal from a recorded `state_head`.
|
|
2429
|
+
*
|
|
2430
|
+
* Single source of truth for the derivation — `validate.health` (W024) and
|
|
2431
|
+
* smart-entry both consume this rather than re-deriving it, so the tri-state
|
|
2432
|
+
* and the hash fence cannot drift apart between surfaces.
|
|
2433
|
+
*
|
|
2434
|
+
* Never throws: every unresolvable input degrades to nulls.
|
|
2435
|
+
*/
|
|
2436
|
+
function readStateHeadFreshness(cwd, stateHead) {
|
|
2437
|
+
const raw = (typeof stateHead === 'string' ? stateHead : '').trim();
|
|
2438
|
+
const stamp = STATE_HEAD_HASH_RE.test(raw) ? raw : null;
|
|
2439
|
+
const head = readGitHeadSha(cwd);
|
|
2440
|
+
let commitsBehind = null;
|
|
2441
|
+
let commitStale = null;
|
|
2442
|
+
if (stamp && head && cwd) {
|
|
2443
|
+
// The stamp must be an ANCESTOR of HEAD before a distance means anything.
|
|
2444
|
+
// `rev-list --count A..B` exits 0 with "0" when A is not reachable from B —
|
|
2445
|
+
// which is what a `reset --hard` to an earlier commit, a rebase or squash
|
|
2446
|
+
// that drops the stamped commit, or a force-push rewriting history all
|
|
2447
|
+
// produce. Without this guard those cases report `commit_stale: false`,
|
|
2448
|
+
// i.e. "known fresh", for a codebase that was actually rewound past the
|
|
2449
|
+
// stamp — collapsing the exact unknown-vs-fresh distinction this tri-state
|
|
2450
|
+
// exists to preserve. A non-ancestor stamp is UNKNOWN, so it stays null.
|
|
2451
|
+
const ancestry = (0, shell_command_projection_cjs_1.execGit)(['merge-base', '--is-ancestor', stamp, head], { cwd });
|
|
2452
|
+
if (ancestry.exitCode === 0) {
|
|
2453
|
+
const r = (0, shell_command_projection_cjs_1.execGit)(['rev-list', '--count', `${stamp}..${head}`], { cwd });
|
|
2454
|
+
if (r.exitCode === 0) {
|
|
2455
|
+
const n = parseInt(r.stdout.trim(), 10);
|
|
2456
|
+
if (Number.isFinite(n)) {
|
|
2457
|
+
commitsBehind = n;
|
|
2458
|
+
// #2573 D4 — deliberately RAW, not thresholded. `commit_stale` means
|
|
2459
|
+
// exactly what its contract says: the codebase has moved since the
|
|
2460
|
+
// stamp. Applying an advisory threshold here would make the field lie
|
|
2461
|
+
// at n < threshold, and W024 needs the true count to threshold on.
|
|
2462
|
+
// Alarm-fatigue is handled at the ALARMING surface, not the
|
|
2463
|
+
// derivation: W024 (the only user-visible consumer) fires at
|
|
2464
|
+
// STATE_HEAD_ADVISORY_COMMITS, which absorbs the `commit_docs: true`
|
|
2465
|
+
// off-by-one. Smart-entry re-exports the raw tri-state as advisory
|
|
2466
|
+
// JSON and is not consumed by classify().
|
|
2467
|
+
commitStale = n > 0;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return {
|
|
2473
|
+
state_head: stamp ? stamp.slice(0, 7) : null,
|
|
2474
|
+
current_commit: head ? head.slice(0, 7) : null,
|
|
2475
|
+
commits_behind: commitsBehind,
|
|
2476
|
+
commit_stale: commitStale,
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* #3354: read `progress.total_phases` out of already-extracted STATE.md
|
|
2481
|
+
* frontmatter as a finite number, or null. Feeds buildStateFrontmatter's
|
|
2482
|
+
* milestoned-but-unbounded withhold so the stored total survives the write
|
|
2483
|
+
* instead of being clobbered by the on-disk phase-directory count.
|
|
2484
|
+
*/
|
|
2485
|
+
function readStoredTotalPhases(existingFm) {
|
|
2486
|
+
if (!existingFm || typeof existingFm !== 'object')
|
|
2487
|
+
return null;
|
|
2488
|
+
const progress = existingFm['progress'];
|
|
2489
|
+
if (!progress || typeof progress !== 'object')
|
|
2490
|
+
return null;
|
|
2491
|
+
const raw = progress['total_phases'];
|
|
2492
|
+
if (raw === null || raw === undefined)
|
|
2493
|
+
return null;
|
|
2494
|
+
if (typeof raw === 'string' && raw.trim() === '')
|
|
2495
|
+
return null;
|
|
2496
|
+
const n = Number(raw);
|
|
2497
|
+
return Number.isFinite(n) ? n : null;
|
|
2498
|
+
}
|
|
2499
|
+
function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
|
|
1684
2500
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
|
1685
2501
|
// that the body no longer has (e.g., Status field removed by an agent).
|
|
1686
2502
|
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
|
|
1687
2503
|
// derivable here without widening the signature (#1882).
|
|
1688
2504
|
const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
|
|
2505
|
+
// #3881 review, second round: an UNPARSEABLE frontmatter block (malformed YAML, a git
|
|
2506
|
+
// merge-conflict marker, a refused anchor) must never be silently REPLACED by a freshly
|
|
2507
|
+
// re-derived one — that destroys the only copy of what the block actually contained, with
|
|
2508
|
+
// no signal to the human that their document was in conflict. `beginFrontmatterReassembly`
|
|
2509
|
+
// (state-transition.cts) already preserves the raw fmPrefix through the pure transform
|
|
2510
|
+
// layer for every `transitionCore` kind; this was the gap — this function re-parses the
|
|
2511
|
+
// ALREADY-preserved `content` and, finding {} + the marker, rebuilt a fresh block anyway,
|
|
2512
|
+
// discarding the raw prefix the transform layer had just protected. Confirmed by execution
|
|
2513
|
+
// against `state complete-phase`/`update`/`patch`/`begin-phase`: each returned success with
|
|
2514
|
+
// the conflict markers gone and a freshly-derived, well-formed frontmatter block in their
|
|
2515
|
+
// place (re-derivation, not deletion — the document never lost its frontmatter FENCE).
|
|
2516
|
+
//
|
|
2517
|
+
// `sanctionedPermanentEmptyFallback` is threaded ONLY from `writeStateMd`, itself consumed
|
|
2518
|
+
// ONLY by `cmdStateSync` (#905) and `/gsd-health --repair`'s `REGENERATE_STATE` — ADR-3408
|
|
2519
|
+
// §8.3's CLOSED list of commands whose documented contract is "body wins, re-derive
|
|
2520
|
+
// unconditionally" (a factory reset / explicit resync). Those two are untouched here: this
|
|
2521
|
+
// guard fires only on the OTHER call path (`syncAndPreserveStateMd`, i.e. every
|
|
2522
|
+
// `readModifyWriteStateMd`-based command), where re-deriving over unparseable content was
|
|
2523
|
+
// never the intended contract in the first place — it was an unhandled gap, not a decision.
|
|
2524
|
+
if (!sanctionedPermanentEmptyFallback && isUnparseableFrontmatter(existingFm)) {
|
|
2525
|
+
return content;
|
|
2526
|
+
}
|
|
1689
2527
|
const body = stripFrontmatter(content);
|
|
1690
2528
|
// #3017: pass the stored milestone from the existing frontmatter so
|
|
1691
2529
|
// buildStateFrontmatter scopes its disk scan to the correct milestone
|
|
1692
2530
|
// instead of auto-deriving (and potentially mis-binding).
|
|
1693
2531
|
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
1694
|
-
|
|
2532
|
+
// #3354: also pass the stored total so buildStateFrontmatter's
|
|
2533
|
+
// milestoned-but-unbounded withhold can preserve it across the write
|
|
2534
|
+
// (the derived progress sub-block replaces the stored one wholesale below,
|
|
2535
|
+
// so an omitted key would otherwise DELETE the stored value).
|
|
2536
|
+
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
1695
2537
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
1696
2538
|
// This prevents a missing Status: field in the body from overwriting a
|
|
1697
2539
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -1726,52 +2568,128 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
|
|
|
1726
2568
|
derivedFm['milestone'] = existingFm['milestone'];
|
|
1727
2569
|
}
|
|
1728
2570
|
}
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
2571
|
+
// ADR-3408 §8.5 (D1): the six empty-only "#905" guards that used to live
|
|
2572
|
+
// here UNCONDITIONALLY are deleted for the write-seam pipeline
|
|
2573
|
+
// (`syncAndPreserveStateMd`, consumed by `readModifyWriteStateMd` and by
|
|
2574
|
+
// `cmdPhaseComplete`'s atomic-commit adapter). An empty derived value now
|
|
2575
|
+
// reaches `applyStatePreservation` unmolested, so the table-driven executor
|
|
2576
|
+
// — not a private copy inside this function — decides whether a curated
|
|
2577
|
+
// frontmatter value survives, and reports the decision via
|
|
2578
|
+
// `divergedFields` when it does. That was the actual D1 bug: these guards
|
|
2579
|
+
// ran BEFORE the executor ever saw the value, so a transform that
|
|
2580
|
+
// deliberately emptied a body line (delta CHANGED) lost silently — the
|
|
2581
|
+
// guard restored the stale frontmatter, the executor's own #1230 delta
|
|
2582
|
+
// check then found "already restored, nothing to do", and
|
|
2583
|
+
// `divergedFields` stayed empty even though a curated value had just won
|
|
2584
|
+
// over a genuine derived-empty.
|
|
1735
2585
|
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2586
|
+
// `writeStateMd`'s two callers — `cmdStateSync` and `/gsd-health --repair`'s
|
|
2587
|
+
// `REGENERATE_STATE` — are §8.3's closed, sanctioned-permanent exception
|
|
2588
|
+
// list: NEITHER ever runs `applyStatePreservation` afterward, because their
|
|
2589
|
+
// whole contract is "re-derive frontmatter FROM the body, body wins" (the
|
|
2590
|
+
// opposite of preservation). For them, these six conditions are the ONLY
|
|
2591
|
+
// mechanism that has ever kept a curated frontmatter value alive when the
|
|
2592
|
+
// body simply carries no annotation for a field at all (most STATE.md
|
|
2593
|
+
// files do not restate every field in body prose on every write) — losing
|
|
2594
|
+
// that would blank `current_phase_name` / `stopped_at` / etc. on every
|
|
2595
|
+
// `state sync`, which is a regression, not this phase's fix: `state sync`'s
|
|
2596
|
+
// output must stay byte-identical (ADR-3408 §8.3 Amendment 2). So the same
|
|
2597
|
+
// six conditions are kept, verbatim, but now gated behind the explicit
|
|
2598
|
+
// `sanctionedPermanentEmptyFallback` parameter — threaded ONLY from
|
|
2599
|
+
// `writeStateMd` — instead of running unconditionally or being duplicated
|
|
2600
|
+
// as a second private copy. This is still ONE enforcement point: the six
|
|
2601
|
+
// conditions exist in exactly one place in the source, selected by caller
|
|
2602
|
+
// identity per the closed §8.3 exception list, never re-derived elsewhere.
|
|
2603
|
+
//
|
|
2604
|
+
// The disagreeing case (a present-but-stale body value vs a fresher
|
|
2605
|
+
// frontmatter value, #948/#3374/§8.5) was never handled here even before
|
|
2606
|
+
// this change: it is governed by applyStatePreservation's
|
|
2607
|
+
// preserve-when-unchanged delta, applied post-sync by the shared
|
|
2608
|
+
// applyPostSyncPreservation pass.
|
|
2609
|
+
if (sanctionedPermanentEmptyFallback) {
|
|
2610
|
+
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
|
|
2611
|
+
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
|
2612
|
+
}
|
|
2613
|
+
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
|
|
2614
|
+
derivedFm['paused_at'] = existingFm['paused_at'];
|
|
2615
|
+
}
|
|
2616
|
+
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
|
|
2617
|
+
derivedFm['current_phase'] = existingFm['current_phase'];
|
|
2618
|
+
}
|
|
2619
|
+
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
|
|
2620
|
+
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
|
|
2621
|
+
}
|
|
2622
|
+
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
|
|
2623
|
+
derivedFm['current_plan'] = existingFm['current_plan'];
|
|
2624
|
+
}
|
|
2625
|
+
// progress is a sub-object: fall back to existing only when the
|
|
2626
|
+
// body+disk scan produced NO progress block at all. When
|
|
2627
|
+
// buildStateFrontmatter did derive a progress block (even a lower one),
|
|
2628
|
+
// that derived value wins — the shouldPreserveExistingProgress
|
|
2629
|
+
// cross-milestone logic is applied later in cmdStateJson on the read
|
|
2630
|
+
// path where it is appropriate.
|
|
2631
|
+
if (!derivedFm['progress'] && existingFm['progress']) {
|
|
2632
|
+
derivedFm['progress'] = (0, state_document_cjs_1.normalizeProgressNumbers)(existingFm['progress']);
|
|
2633
|
+
}
|
|
1766
2634
|
}
|
|
1767
2635
|
// #2202: carry forward any existing frontmatter key that the schema does not
|
|
1768
2636
|
// own, so custom/unknown keys are not silently dropped on every mutating verb.
|
|
1769
2637
|
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
|
|
1770
|
-
//
|
|
2638
|
+
// sanctioned-permanent guards above, when they ran) still win.
|
|
1771
2639
|
for (const key of Object.keys(existingFm)) {
|
|
1772
|
-
if (
|
|
1773
|
-
|
|
1774
|
-
|
|
2640
|
+
if (key in derivedFm || existingFm[key] === undefined)
|
|
2641
|
+
continue;
|
|
2642
|
+
// #2573: a `source: 'free'` field is the writer's word on every write and
|
|
2643
|
+
// carries no preservation (see the FieldSource doc). When buildStateFrontmatter
|
|
2644
|
+
// omits it — `state_head` outside a git repo, per its `if (stateHead)` guard —
|
|
2645
|
+
// carrying the old value forward would re-assert provenance the file no longer
|
|
2646
|
+
// has: a stale state_head would claim STATE.md was written against a commit it
|
|
2647
|
+
// wasn't, contradicting its own ADR-1769 row.
|
|
2648
|
+
//
|
|
2649
|
+
// Narrow the skip to `source: 'free'`, NOT every `derive` row. `last_activity`
|
|
2650
|
+
// ({source:'body'}) and the `progress.*` rows ({source:'disk'}) are also
|
|
2651
|
+
// `derive`, but they are body/disk-sourced and MUST still carry forward when
|
|
2652
|
+
// the writer omits them this pass — dropping `last_activity` here is silent
|
|
2653
|
+
// frontmatter data loss and would defeat #2570's staleness fix downstream.
|
|
2654
|
+
// `last_updated` and `gsd_state_version` are the only other `free` rows and are
|
|
2655
|
+
// both produced unconditionally by buildStateFrontmatter, so this loop never
|
|
2656
|
+
// reaches them; `state_head` is the sole field the skip governs. Consult the
|
|
2657
|
+
// table rather than naming fields, so the policy stays single-sourced.
|
|
2658
|
+
const classification = stateTransitionMod.getFieldClassification(key);
|
|
2659
|
+
if (classification && classification.source === 'free')
|
|
2660
|
+
continue;
|
|
2661
|
+
// ADR-3408 §8.1/§8.5 (D1 follow-on — found by probe, not predicted by the
|
|
2662
|
+
// design): a `preserve-when-unchanged` / `preserve-always` field must be
|
|
2663
|
+
// decided ONLY by `applyStatePreservation` — the single enforcement point
|
|
2664
|
+
// — never by this generic carry-forward, on the write-seam path. Before
|
|
2665
|
+
// the six sanctioned-permanent guards above were gated behind
|
|
2666
|
+
// `sanctionedPermanentEmptyFallback` (D1), this loop's `key in derivedFm`
|
|
2667
|
+
// check was effectively always true for a field the guards had already
|
|
2668
|
+
// restored, so this branch was unreachable for it and the distinction
|
|
2669
|
+
// never mattered. With the guards now OFF on the write-seam path,
|
|
2670
|
+
// `derivedFm` genuinely lacks the key when the body carries no
|
|
2671
|
+
// annotation — and without this skip, this loop silently resurrects the
|
|
2672
|
+
// exact stale value the executor's delta rule (§8.5 Row 2) just decided
|
|
2673
|
+
// to discard, re-introducing the D1 bug through a second, unrelated code
|
|
2674
|
+
// path (confirmed live: an A5-shaped probe restored `current_phase_name`
|
|
2675
|
+
// via THIS loop even with the six guards deleted).
|
|
2676
|
+
//
|
|
2677
|
+
// Gated to the write-seam path ONLY (`!sanctionedPermanentEmptyFallback`)
|
|
2678
|
+
// — `writeStateMd`'s two sanctioned-permanent callers never run
|
|
2679
|
+
// `applyStatePreservation` at all, so unconditionally skipping here would
|
|
2680
|
+
// blank fields this loop has always carried forward for them (e.g.
|
|
2681
|
+
// `last_activity_desc`, which was never one of the six explicit guards
|
|
2682
|
+
// above but relied on THIS loop for its empty-case fallback), breaking
|
|
2683
|
+
// `state sync`'s required byte-identical output for a field D1 never
|
|
2684
|
+
// named. On the write-seam path this executor-only rule genuinely widens
|
|
2685
|
+
// beyond the original six fields (e.g. also covers `last_activity_desc`)
|
|
2686
|
+
// — a deliberate, in-scope consequence of "one enforcement point", not a
|
|
2687
|
+
// separate defect.
|
|
2688
|
+
if (!sanctionedPermanentEmptyFallback &&
|
|
2689
|
+
classification &&
|
|
2690
|
+
(classification.preservation === 'preserve-when-unchanged' || classification.preservation === 'preserve-always'))
|
|
2691
|
+
continue;
|
|
2692
|
+
derivedFm[key] = existingFm[key];
|
|
1775
2693
|
}
|
|
1776
2694
|
// #2567: guard the information-losing direction — a stale archive
|
|
1777
2695
|
// "Last activity:" line must not overwrite a newer frontmatter value.
|
|
@@ -1790,6 +2708,11 @@ function syncStateFrontmatter(content, cwd, authoritativeFm) {
|
|
|
1790
2708
|
}
|
|
1791
2709
|
}
|
|
1792
2710
|
}
|
|
2711
|
+
// #3257: propagate full-line frontmatter comments from the extracted source onto the
|
|
2712
|
+
// rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
|
|
2713
|
+
// skip the Symbol-keyed channel, so without this the comments would be lost here even
|
|
2714
|
+
// though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation).
|
|
2715
|
+
propagateCommentChannel(existingFm, derivedFm);
|
|
1793
2716
|
const yamlStr = reconstructFrontmatter(derivedFm);
|
|
1794
2717
|
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
1795
2718
|
}
|
|
@@ -2040,7 +2963,23 @@ function withStateLock(statePath, fn) {
|
|
|
2040
2963
|
* @param clock
|
|
2041
2964
|
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
|
2042
2965
|
*/
|
|
2043
|
-
|
|
2966
|
+
/**
|
|
2967
|
+
* ADR-3473 §8.6: `writeStateMd` is ADR-3408 §8.3's sanctioned-exception write
|
|
2968
|
+
* path — only a `rebuildStateTransaction` may travel it. Enforced here rather
|
|
2969
|
+
* than left to caller discipline: the transaction TYPE is what makes the two
|
|
2970
|
+
* sanctioned exceptions (`cmdStateSync`, `REGENERATE_STATE`) greppable and
|
|
2971
|
+
* closed, and an `open()` transaction reaching this function would mean a
|
|
2972
|
+
* preservation-governed write silently skipped preservation.
|
|
2973
|
+
*/
|
|
2974
|
+
function writeStateMd(statePath, content, transaction, cwd, clock) {
|
|
2975
|
+
if (transaction.kind !== 'rebuild') {
|
|
2976
|
+
const err = new Error(`writeStateMd: expected a 'rebuild' transaction, got '${transaction.kind}'. writeStateMd is ` +
|
|
2977
|
+
'ADR-3408 §8.3\'s sanctioned-exception write path (cmdStateSync / REGENERATE_STATE only) — ' +
|
|
2978
|
+
'only rebuildStateTransaction() may travel it (ADR-3473 §8.6). An open() transaction here ' +
|
|
2979
|
+
'would silently skip preservation for a write that was supposed to run it.');
|
|
2980
|
+
err.code = 'STATE_TRANSACTION_KIND_INVALID';
|
|
2981
|
+
throw err;
|
|
2982
|
+
}
|
|
2044
2983
|
const lockPath = acquireStateLock(statePath, clock);
|
|
2045
2984
|
// Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
|
|
2046
2985
|
// concurrent writer landing in the (now-closed) scan→lock window.
|
|
@@ -2060,13 +2999,354 @@ function writeStateMd(statePath, content, cwd, clock) {
|
|
|
2060
2999
|
// files that buildStateFrontmatter must see (#1967).
|
|
2061
3000
|
if (cwd)
|
|
2062
3001
|
_diskScanCache.delete(cwd);
|
|
2063
|
-
|
|
3002
|
+
// ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
|
|
3003
|
+
// sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
|
|
3004
|
+
// the sanctioned-permanent empty-field fallback is now DERIVED FROM THE
|
|
3005
|
+
// TRANSACTION KIND (ADR-3473 §8.6) rather than asserted by a literal
|
|
3006
|
+
// `true` at this call site: only a `rebuild` transaction can reach this
|
|
3007
|
+
// function (enforced above), so `transaction.kind === 'rebuild'` is
|
|
3008
|
+
// always `true` here today, but the derivation is what keeps the
|
|
3009
|
+
// fallback's scope tied to the transaction type rather than a
|
|
3010
|
+
// hard-coded constant that could silently drift from it.
|
|
3011
|
+
const synced = syncStateFrontmatter(content, cwd, undefined, transaction.kind === 'rebuild');
|
|
2064
3012
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
2065
3013
|
}
|
|
2066
3014
|
finally {
|
|
2067
3015
|
releaseStateLock(lockPath);
|
|
2068
3016
|
}
|
|
2069
3017
|
}
|
|
3018
|
+
/**
|
|
3019
|
+
* #3374: the shared post-sync preservation pass — the pre/post body-source
|
|
3020
|
+
* snapshot + table-driven `applyStatePreservation` + #2736 authoritative
|
|
3021
|
+
* re-assert sequence. Extracted from readModifyWriteStateMd so
|
|
3022
|
+
* `cmdPhaseComplete`'s atomic-commit adapter (phase.cts) — which syncs
|
|
3023
|
+
* STATE.md directly because it is committed atomically with
|
|
3024
|
+
* ROADMAP/REQUIREMENTS and so cannot go through the RMW wrapper — applies the
|
|
3025
|
+
* identical policy instead of a second, weaker encoding. Previously the
|
|
3026
|
+
* adapter had no preservation at all, letting a stale body `Stopped at:` line
|
|
3027
|
+
* silently clobber a fresher frontmatter `stopped_at` on every phase
|
|
3028
|
+
* completion (#3374 Variant A).
|
|
3029
|
+
*
|
|
3030
|
+
* NOT applied on the writeStateMd path: `state sync`'s contract is the
|
|
3031
|
+
* opposite by design (#905 — "body annotation beats existing frontmatter when
|
|
3032
|
+
* both are present": sync exists to re-derive frontmatter from the body), so a
|
|
3033
|
+
* blanket preservation pass there re-locks stale frontmatter. The
|
|
3034
|
+
* milestone-complete equivalent of the #3374 exposure is tracked as a
|
|
3035
|
+
* follow-up (see PR #3491 / the closed PR #3442 review's MAJOR finding).
|
|
3036
|
+
*
|
|
3037
|
+
* `originalContent` is the pre-write on-disk content (drives the #1230
|
|
3038
|
+
* pre-snapshots), `transformedContent` is the post-transform content (the
|
|
3039
|
+
* sync only rewrites the frontmatter block, so its body IS the post-write
|
|
3040
|
+
* body), and `syncedContent` is what `syncStateFrontmatter` produced.
|
|
3041
|
+
*/
|
|
3042
|
+
/**
|
|
3043
|
+
* #3471 Fix: `StatePreservationOptions` is silently mis-consumable by any
|
|
3044
|
+
* non-TypeScript caller — `tsc` only type-checks src/, so a plain-.cjs test
|
|
3045
|
+
* (or any future JS caller) can pass a boolean where this options object
|
|
3046
|
+
* goes and both functions below would previously proceed with `resync`,
|
|
3047
|
+
* `authoritativeFm`, `deriveProgressKeys`, and `divergedFields` all
|
|
3048
|
+
* `undefined`, degrading to a well-formed-looking but silently-empty
|
|
3049
|
+
* `divergedFields: []` — exactly the "stale but present" failure shape
|
|
3050
|
+
* ADR-3408 exists to remove. This is a contract assertion (caller-shape
|
|
3051
|
+
* only), not field-level validation — mirrors `throwUnwiredRow`'s
|
|
3052
|
+
* structured-error shape in src/state-transition.cts.
|
|
3053
|
+
*/
|
|
3054
|
+
function assertStatePreservationOptions(options, caller) {
|
|
3055
|
+
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
|
|
3056
|
+
const err = new Error(`${caller}: options argument must be a StatePreservationOptions object, got ${typeof options === 'object' ? 'array/null' : typeof options}. ` +
|
|
3057
|
+
'This function takes a single options object as its final ' +
|
|
3058
|
+
'parameter, not positional resync/authoritativeFm/deriveProgressKeys/divergedFields arguments (#3471).');
|
|
3059
|
+
err.code = 'STATE_PRESERVATION_OPTIONS_INVALID';
|
|
3060
|
+
err.receivedType = Array.isArray(options) ? 'array' : typeof options;
|
|
3061
|
+
throw err;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
|
|
3065
|
+
assertStatePreservationOptions(options, 'applyPostSyncPreservation');
|
|
3066
|
+
const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
|
|
3067
|
+
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
3068
|
+
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
3069
|
+
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
3070
|
+
// when the body's source field was NOT changed by the transform, the
|
|
3071
|
+
// existing frontmatter value (e.g. a hand-set 'completed') must win over
|
|
3072
|
+
// the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
|
|
3073
|
+
// Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
|
|
3074
|
+
// above (null when resync:true) — these are independent snapshots.
|
|
3075
|
+
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
3076
|
+
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
3077
|
+
const preFmSnapshot = extractFrontmatter(originalContent, statePath);
|
|
3078
|
+
// #3881 review, second round: `syncStateFrontmatter` above already declines to re-derive
|
|
3079
|
+
// over an UNPARSEABLE original frontmatter block (its own matching guard), so `syncedContent`
|
|
3080
|
+
// here is `transformedContent` verbatim. But this function's own downstream preservation
|
|
3081
|
+
// machinery (`applyStatePreservation` + the `authoritativeFm` reassertion below) reads
|
|
3082
|
+
// `postFm = extractFrontmatter(syncedContent, ...)` — {} + the marker, since the block still
|
|
3083
|
+
// doesn't parse — restores curated fields from `transaction.snapshot`, and reconstructs a
|
|
3084
|
+
// FRESH frontmatter block from the result, destroying the raw block a second time even
|
|
3085
|
+
// though `syncStateFrontmatter` just finished protecting it. `applyPostSyncPreservation` is
|
|
3086
|
+
// reached ONLY via the non-sanctioned path (`syncAndPreserveStateMd`; `writeStateMd`'s two
|
|
3087
|
+
// ADR-3408 §8.3 closed-list callers — `cmdStateSync` #905 and `/gsd-health --repair`'s
|
|
3088
|
+
// `REGENERATE_STATE` — never call it at all), so this guard needs no extra parameter to stay
|
|
3089
|
+
// scoped off that list. Confirmed by execution: `state begin-phase` on a conflict-marked
|
|
3090
|
+
// STATE.md reached exactly this second clobber even after the `syncStateFrontmatter` fix.
|
|
3091
|
+
if (isUnparseableFrontmatter(preFmSnapshot)) {
|
|
3092
|
+
return transformedContent;
|
|
3093
|
+
}
|
|
3094
|
+
const preBody = stripFrontmatter(originalContent);
|
|
3095
|
+
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
3096
|
+
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
3097
|
+
// mirroring buildStateFrontmatter's sessionBodyScope logic.
|
|
3098
|
+
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
3099
|
+
// Archive prose) must not interfere with the delta comparison.
|
|
3100
|
+
const preSessionMatch = matchSessionSection(preBody);
|
|
3101
|
+
const preSessionScope = preSessionMatch ?? preBody;
|
|
3102
|
+
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
3103
|
+
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
3104
|
+
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
3105
|
+
// this write does NOT change that line, the curated frontmatter value must
|
|
3106
|
+
// win over syncStateFrontmatter's body re-derivation (which can harvest a
|
|
3107
|
+
// wrong parenthetical aside — #1695). Gated by the field-classification
|
|
3108
|
+
// table's preserve-always row so the rule lives in one place.
|
|
3109
|
+
const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
|
|
3110
|
+
// #3258: snapshot the body sources for the additional preserve-when-unchanged
|
|
3111
|
+
// rows applyStatePreservation now honors (last_activity_desc, paused_at,
|
|
3112
|
+
// current_phase, current_plan). Each mirrors buildStateFrontmatter's
|
|
3113
|
+
// derivation so the #1230 delta ("did THIS write change the source?") is
|
|
3114
|
+
// accurate: current_phase combines `Current Phase` with the prose `Phase:`
|
|
3115
|
+
// fallback (parseProsePhaseField, scoped to ## Current Position); paused_at
|
|
3116
|
+
// is session-scoped (mirrors stopped_at); last_activity_desc combines the
|
|
3117
|
+
// `Last Activity Description` field with the prose desc fallback.
|
|
3118
|
+
const preCurrentPositionScope = matchCurrentPositionSection(preBody) ?? preBody;
|
|
3119
|
+
const preBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Plan');
|
|
3120
|
+
const preBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(preBody, 'Current Phase')
|
|
3121
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(preCurrentPositionScope, 'Phase')).phase;
|
|
3122
|
+
const preBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Paused At');
|
|
3123
|
+
const preBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity')
|
|
3124
|
+
?? (0, state_document_cjs_1.stateExtractField)(preBody, 'Last activity');
|
|
3125
|
+
const preBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(preBody, 'Last Activity Description')
|
|
3126
|
+
?? parseProseLastActivityField(preBodyLastActivityRaw).description;
|
|
3127
|
+
// Post-transform body source fields used for the delta comparison (#1230).
|
|
3128
|
+
// Use `transformedContent` (not `syncedContent`): syncStateFrontmatter only
|
|
3129
|
+
// rewrites the frontmatter block, so the body is identical in both — and we
|
|
3130
|
+
// need the body the transform produced. Strip frontmatter so the YAML
|
|
3131
|
+
// status key cannot shadow the body field we are tracking.
|
|
3132
|
+
const postBody = stripFrontmatter(transformedContent);
|
|
3133
|
+
const postBodyStatus = (0, state_document_cjs_1.stateExtractField)(postBody, 'Status');
|
|
3134
|
+
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
3135
|
+
// consistent with the pre-transform snapshot above and buildStateFrontmatter.
|
|
3136
|
+
const postSessionMatch = matchSessionSection(postBody);
|
|
3137
|
+
const postSessionScope = postSessionMatch ?? postBody;
|
|
3138
|
+
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
3139
|
+
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
3140
|
+
// current_phase_name delta comparison.
|
|
3141
|
+
const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
|
|
3142
|
+
// #3258: post-transform body sources for the preserve-when-unchanged rows
|
|
3143
|
+
// added in #3258 (mirrors the pre-transform block above).
|
|
3144
|
+
const postCurrentPositionScope = matchCurrentPositionSection(postBody) ?? postBody;
|
|
3145
|
+
const postBodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Plan');
|
|
3146
|
+
const postBodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(postBody, 'Current Phase')
|
|
3147
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(postCurrentPositionScope, 'Phase')).phase;
|
|
3148
|
+
const postBodyPausedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Paused At');
|
|
3149
|
+
const postBodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity')
|
|
3150
|
+
?? (0, state_document_cjs_1.stateExtractField)(postBody, 'Last activity');
|
|
3151
|
+
const postBodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(postBody, 'Last Activity Description')
|
|
3152
|
+
?? parseProseLastActivityField(postBodyLastActivityRaw).description;
|
|
3153
|
+
// #3468: single channel for every preserve-when-unchanged row. Before this
|
|
3154
|
+
// change, seven body-source pre/post pairs travelled in two different
|
|
3155
|
+
// shapes — this map for four fields, six dedicated parameters
|
|
3156
|
+
// (preBodyStatus/postBodyStatus, preBodyStoppedAt/postBodyStoppedAt,
|
|
3157
|
+
// preBodyPhaseSource/postBodyPhaseSource) for the other three — same data,
|
|
3158
|
+
// same purpose, which is exactly why applyStatePreservation needed a
|
|
3159
|
+
// hand-written branch per field instead of one loop over the table. Every
|
|
3160
|
+
// row FIELD_CLASSIFICATION declares preserve-when-unchanged MUST appear
|
|
3161
|
+
// here — an omission now throws (STATE_PRESERVATION_UNWIRED_ROW, ADR-3408
|
|
3162
|
+
// §8.2) at the first write rather than becoming a quiet preservation bug.
|
|
3163
|
+
// Note current_phase_name's source is the body `Phase:` line, deliberately
|
|
3164
|
+
// a DIFFERENT source from current_phase's: the key names the field the
|
|
3165
|
+
// policy GUARDS, not the body field it reads.
|
|
3166
|
+
const bodyDeltas = {
|
|
3167
|
+
last_activity_desc: { pre: preBodyLastActivityDesc, post: postBodyLastActivityDesc },
|
|
3168
|
+
paused_at: { pre: preBodyPausedAt, post: postBodyPausedAt },
|
|
3169
|
+
current_phase: { pre: preBodyCurrentPhase, post: postBodyCurrentPhase },
|
|
3170
|
+
current_plan: { pre: preBodyCurrentPlan, post: postBodyCurrentPlan },
|
|
3171
|
+
status: { pre: preBodyStatus, post: postBodyStatus },
|
|
3172
|
+
stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
|
|
3173
|
+
current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
|
|
3174
|
+
// ADR-3473 §8.7 (#3872): `last_activity` is the one `FRONTMATTER_BODY_SOURCE`
|
|
3175
|
+
// key that is NOT `preserve-when-unchanged` (it is `derive` — always
|
|
3176
|
+
// re-stamped from the body) and so was never part of this map before.
|
|
3177
|
+
// Added ONLY for `reconcileReportedFields`'s consumption below (via
|
|
3178
|
+
// `preWriteState.bodyDeltas`) — harmless here, since
|
|
3179
|
+
// `applyPreserveWhenUnchanged` is dispatched by
|
|
3180
|
+
// `getPreserveWhenUnchangedFields()`, never by iterating this object's
|
|
3181
|
+
// keys, so an extra non-preserve-when-unchanged entry changes no
|
|
3182
|
+
// preservation behavior.
|
|
3183
|
+
last_activity: { pre: preBodyLastActivityRaw, post: postBodyLastActivityRaw },
|
|
3184
|
+
};
|
|
3185
|
+
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
3186
|
+
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
3187
|
+
// in the STATE.md Transition Module. progress / status / stopped_at /
|
|
3188
|
+
// current_phase_name are all governed by their FIELD_CLASSIFICATION row —
|
|
3189
|
+
// one policy source, not three drifting encodings. #3258 extends the same
|
|
3190
|
+
// pass to last_activity_desc / paused_at / current_phase / current_plan
|
|
3191
|
+
// (preserve-when-unchanged) and milestone / milestone_name (preserve-if-
|
|
3192
|
+
// placeholder). Behavior-identical to the pre-#1796 inline block for the
|
|
3193
|
+
// original four fields; this is the absorption ADR-1769 / CONTEXT.md
|
|
3194
|
+
// already claimed shipped.
|
|
3195
|
+
const postFm = extractFrontmatter(syncedContent, statePath);
|
|
3196
|
+
// #3469 (ADR-3408 §8.5): snapshot the freshly-synced (pre-preservation)
|
|
3197
|
+
// frontmatter so a caller that wants visibility into "did preservation
|
|
3198
|
+
// restore a curated value over a disagreeing derived one" can diff against
|
|
3199
|
+
// it via the optional `divergedFields` out-param below. Additive only:
|
|
3200
|
+
// callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
|
|
3201
|
+
// nothing extra and see no change to `synced`/the returned content.
|
|
3202
|
+
const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
|
|
3203
|
+
// ADR-3473 §8.6: the pre-write snapshot + policy flags now travel as ONE
|
|
3204
|
+
// transaction rather than as a nullable `preFm` alongside the always-present
|
|
3205
|
+
// `preFmSnapshot` (same source, same extractFrontmatter call — `preFm` was
|
|
3206
|
+
// `preFmSnapshot` with the `resync` policy baked in by nulling it, which is
|
|
3207
|
+
// what made `applyPreserveAlways` inert on the default resyncing write
|
|
3208
|
+
// path — #3756).
|
|
3209
|
+
const transaction = stateTransitionMod.openStateTransaction({
|
|
3210
|
+
snapshot: preFmSnapshot,
|
|
3211
|
+
resync,
|
|
3212
|
+
deriveProgressKeys: deriveProgressKeys === true,
|
|
3213
|
+
bodyDeltas,
|
|
3214
|
+
explicitProgressField: explicitProgressField === true,
|
|
3215
|
+
});
|
|
3216
|
+
// ADR-3473 §8.7 (#3872): fill the caller's out-param with the TRANSACTION'S
|
|
3217
|
+
// OWN snapshot object (not a second `extractFrontmatter(originalContent)`
|
|
3218
|
+
// derivation — `transaction.snapshot === preFmSnapshot`, reusing it is the
|
|
3219
|
+
// whole point) plus the pre-write body, so `reconcileReportedFields` can
|
|
3220
|
+
// diff persisted-vs-pre-write instead of re-deriving either side itself.
|
|
3221
|
+
if (preWriteState) {
|
|
3222
|
+
preWriteState.fm = transaction.snapshot;
|
|
3223
|
+
preWriteState.body = preBody;
|
|
3224
|
+
// ADR-3473 §8.7 (#3872): the pre/post body-source delta for every
|
|
3225
|
+
// FRONTMATTER_BODY_SOURCE key — see `StatePreWriteSnapshot`'s docstring
|
|
3226
|
+
// for why `reconcileReportedFields` needs this instead of a raw
|
|
3227
|
+
// frontmatter diff for these specific keys.
|
|
3228
|
+
preWriteState.bodyDeltas = bodyDeltas;
|
|
3229
|
+
}
|
|
3230
|
+
const preservation = applyStatePreservation({ transaction, postFm });
|
|
3231
|
+
if (divergedFields && preservationInputSnapshot) {
|
|
3232
|
+
// §8.5's "liberal but visible": every field whose value actually
|
|
3233
|
+
// differs before vs after `applyStatePreservation` is a field where the
|
|
3234
|
+
// curated (frontmatter) value won over a disagreeing freshly-derived
|
|
3235
|
+
// one — regardless of which policy executor fired. Diffing the object
|
|
3236
|
+
// (rather than special-casing which executor mutated it) is intentional:
|
|
3237
|
+
// it stays correct if a future FIELD_CLASSIFICATION row adds a new
|
|
3238
|
+
// preservation policy without this function needing to know about it.
|
|
3239
|
+
for (const key of Object.keys(preservation.postFm)) {
|
|
3240
|
+
const before = preservationInputSnapshot[key];
|
|
3241
|
+
const after = preservation.postFm[key];
|
|
3242
|
+
// ADR-3473 §8.7 (#3872 standards-axis finding): route through the ONE
|
|
3243
|
+
// owner of this comparison rule (`stateFieldValuesDiffer`, defined
|
|
3244
|
+
// below) instead of carrying a second inline `JSON.stringify`-vs-`!==`
|
|
3245
|
+
// copy — this is exactly the duplicated-rule shape this epic exists to
|
|
3246
|
+
// remove. `stateFieldValuesDiffer` is a function declaration (hoisted),
|
|
3247
|
+
// so calling it here, above its textual definition, is safe.
|
|
3248
|
+
if (stateFieldValuesDiffer(before, after))
|
|
3249
|
+
divergedFields.push(key);
|
|
3250
|
+
}
|
|
3251
|
+
// ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
|
|
3252
|
+
// deleted rather than merely relocated): the loop above can only see a
|
|
3253
|
+
// field that `applyStatePreservation` itself RESTORED — it diffs
|
|
3254
|
+
// `postFm` before vs after the executor ran, and `preserve-when-unchanged`
|
|
3255
|
+
// never adds an absent key back when the body source changed this write
|
|
3256
|
+
// (the delta rule correctly lets the empty derived value win, so `postFm`
|
|
3257
|
+
// never gains the key at all). That means a curated value can vanish —
|
|
3258
|
+
// deliberately, per policy — with NOTHING in the loop above to report it.
|
|
3259
|
+
// "Liberal but visible" requires the discard itself to be named, not just
|
|
3260
|
+
// a restore. Scoped to exactly the fields `bodyDeltas` tracks
|
|
3261
|
+
// (preserve-when-unchanged rows only — `preserve-always`/`progress` and
|
|
3262
|
+
// `preserve-if-placeholder`/`milestone*` are unaffected by the delta rule
|
|
3263
|
+
// and already fully covered by the restore-diff loop above).
|
|
3264
|
+
for (const [field, delta] of Object.entries(bodyDeltas)) {
|
|
3265
|
+
if (divergedFields.includes(field))
|
|
3266
|
+
continue; // already reported as a restore above
|
|
3267
|
+
const before = preFmSnapshot[field];
|
|
3268
|
+
const beforeIsReal = typeof before === 'string' && before.trim().length > 0;
|
|
3269
|
+
if (!beforeIsReal)
|
|
3270
|
+
continue; // nothing curated existed to discard
|
|
3271
|
+
if (delta.pre === delta.post)
|
|
3272
|
+
continue; // body source unchanged — governed by the restore branch, not the discard rule
|
|
3273
|
+
const after = preservation.postFm[field];
|
|
3274
|
+
const afterIsEmpty = after === undefined || after === null
|
|
3275
|
+
|| (typeof after === 'string' && after.trim().length === 0);
|
|
3276
|
+
if (afterIsEmpty)
|
|
3277
|
+
divergedFields.push(field);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
|
|
3281
|
+
// layouts with no body `Phase:` line, both phase-source snapshots are null
|
|
3282
|
+
// (equal), so the #1695 restore fires and would put the stale pre-transition
|
|
3283
|
+
// name back over the authoritative one. Intent beats both the prose
|
|
3284
|
+
// re-derivation and the curated restore — the transition just resolved it.
|
|
3285
|
+
let authoritativeReasserted = false;
|
|
3286
|
+
if (authoritativeFm) {
|
|
3287
|
+
for (const [key, value] of Object.entries(authoritativeFm)) {
|
|
3288
|
+
if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
|
|
3289
|
+
preservation.postFm[key] = value;
|
|
3290
|
+
authoritativeReasserted = true;
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
if (preservation.mutated || authoritativeReasserted) {
|
|
3295
|
+
// #3742: preservation RESTORES frontmatter keys the body-derived rebuild
|
|
3296
|
+
// could not produce (e.g. `current_phase` on a layout with no body
|
|
3297
|
+
// `**Current Phase:**` line) — but the comment channel was filtered
|
|
3298
|
+
// against the pre-restore key set during sync, so a full-line comment
|
|
3299
|
+
// attached to a restored key died with nothing to re-attach it. Propagate
|
|
3300
|
+
// the channel from the PRE-WRITE snapshot here, after the restores, so a
|
|
3301
|
+
// comment's survival depends on its key surviving the whole write — not
|
|
3302
|
+
// on which body line happened to feed the rebuild. Merge semantics
|
|
3303
|
+
// (propagateCommentChannel) keep any channel the synced content already
|
|
3304
|
+
// carried. No resync gate: this is the RMW path, where `resync` is the
|
|
3305
|
+
// DEFAULT (readModifyWriteStateMd derives it as `options.resync !==
|
|
3306
|
+
// false`) and preservation itself runs regardless — the factory-reset
|
|
3307
|
+
// semantic the #3742 review worried about lives in writeStateMd's
|
|
3308
|
+
// `rebuild` transactions, which never reach this branch.
|
|
3309
|
+
if (preFmSnapshot && !isUnparseableFrontmatter(preFmSnapshot)) {
|
|
3310
|
+
propagateCommentChannel(preFmSnapshot, preservation.postFm);
|
|
3311
|
+
}
|
|
3312
|
+
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
3313
|
+
const body = stripFrontmatter(syncedContent);
|
|
3314
|
+
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
3315
|
+
}
|
|
3316
|
+
return syncedContent;
|
|
3317
|
+
}
|
|
3318
|
+
/**
|
|
3319
|
+
* ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
|
|
3320
|
+
* `applyPostSyncPreservation`, as a single named `content -> content`
|
|
3321
|
+
* function. Every STATE.md write that (a) is not one of the two sanctioned-
|
|
3322
|
+
* permanent exceptions (`cmdStateSync`, `REGENERATE_STATE` — §8.3's closed
|
|
3323
|
+
* exception list, ADR Amendment 2) and (b) needs a non-standard I/O envelope
|
|
3324
|
+
* calls THIS — never `syncStateFrontmatter` + `applyPostSyncPreservation`
|
|
3325
|
+
* assembled locally. §8.3: "Assembling the stages at a call site is a
|
|
3326
|
+
* re-derivation even when every step calls the owner." Phase 2 (#3469) found
|
|
3327
|
+
* exactly that shape live in `cmdPhaseComplete`'s atomic-commit adapter
|
|
3328
|
+
* (phase.cts) — every step called an owner, so the drift guard and an
|
|
3329
|
+
* owner-level test both stayed green while the composition itself was free
|
|
3330
|
+
* to diverge from `readModifyWriteStateMd`'s.
|
|
3331
|
+
*
|
|
3332
|
+
* Both current non-RMW callers of the pair — `readModifyWriteStateMd` and
|
|
3333
|
+
* `cmdPhaseComplete`'s atomic 3-file commit adapter — now call this instead
|
|
3334
|
+
* of assembling the two stages themselves. `cmdMilestoneComplete` (the
|
|
3335
|
+
* #3374-shaped exposure `applyPostSyncPreservation`'s own docstring flagged
|
|
3336
|
+
* as a follow-up) is the third.
|
|
3337
|
+
*
|
|
3338
|
+
* Returns CONTENT ONLY — a caller that needs its own I/O envelope (a lock,
|
|
3339
|
+
* an atomic multi-file commit) supplies it around this call; this function
|
|
3340
|
+
* never takes over the write.
|
|
3341
|
+
*
|
|
3342
|
+
* `divergedFields` is passed straight through to `applyPostSyncPreservation`
|
|
3343
|
+
* — see its own docstring.
|
|
3344
|
+
*/
|
|
3345
|
+
function syncAndPreserveStateMd(originalContent, transformedContent, statePath, cwd, options) {
|
|
3346
|
+
assertStatePreservationOptions(options, 'syncAndPreserveStateMd');
|
|
3347
|
+
const synced = syncStateFrontmatter(transformedContent, cwd, options.authoritativeFm);
|
|
3348
|
+
return applyPostSyncPreservation(originalContent, transformedContent, synced, statePath, options);
|
|
3349
|
+
}
|
|
2070
3350
|
/**
|
|
2071
3351
|
* Atomic read-modify-write for STATE.md.
|
|
2072
3352
|
* Holds the lock across the entire read -> transform -> write cycle,
|
|
@@ -2092,36 +3372,6 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
2092
3372
|
const lockPath = acquireStateLock(statePath, clock);
|
|
2093
3373
|
try {
|
|
2094
3374
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
2095
|
-
// Snapshot the existing progress block BEFORE the transform so we can
|
|
2096
|
-
// restore it when resync is false.
|
|
2097
|
-
const preFm = resync ? null : extractFrontmatter(content, statePath);
|
|
2098
|
-
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
2099
|
-
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
2100
|
-
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
2101
|
-
// when the body's source field was NOT changed by the transform, the
|
|
2102
|
-
// existing frontmatter value (e.g. a hand-set 'completed') must win over
|
|
2103
|
-
// the body-derived value (e.g. 'verifying' from a stale "Status: Verifying
|
|
2104
|
-
// Phase 3" line that an earlier tool wrote). We do NOT disturb `preFm`
|
|
2105
|
-
// above (null when resync:true) — these are independent snapshots.
|
|
2106
|
-
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
2107
|
-
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
2108
|
-
const preBody = stripFrontmatter(content);
|
|
2109
|
-
const preFmSnapshot = extractFrontmatter(content, statePath);
|
|
2110
|
-
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
2111
|
-
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
2112
|
-
// mirroring buildStateFrontmatter's sessionBodyScope logic (line ~1172).
|
|
2113
|
-
// A stale "Stopped at:" in a non-Session section (e.g. Session Continuity
|
|
2114
|
-
// Archive prose) must not interfere with the delta comparison.
|
|
2115
|
-
const preSessionMatch = matchSessionSection(preBody);
|
|
2116
|
-
const preSessionScope = preSessionMatch ?? preBody;
|
|
2117
|
-
const preBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(preSessionScope, 'Stopped at');
|
|
2118
|
-
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
|
2119
|
-
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
|
2120
|
-
// this write does NOT change that line, the curated frontmatter value must
|
|
2121
|
-
// win over syncStateFrontmatter's body re-derivation (which can harvest a
|
|
2122
|
-
// wrong parenthetical aside — #1695). Gated by the field-classification
|
|
2123
|
-
// table's preserve-always row so the rule lives in one place.
|
|
2124
|
-
const preBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(preBody, 'Phase');
|
|
2125
3375
|
const modified = transformFn(content);
|
|
2126
3376
|
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
|
2127
3377
|
// the file. An unconditional write would bump `last_updated`, reset
|
|
@@ -2133,54 +3383,23 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
2133
3383
|
if (modified === content) {
|
|
2134
3384
|
return false;
|
|
2135
3385
|
}
|
|
2136
|
-
|
|
2137
|
-
//
|
|
2138
|
-
//
|
|
2139
|
-
//
|
|
2140
|
-
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
const postSessionMatch = matchSessionSection(postBody);
|
|
2145
|
-
const postSessionScope = postSessionMatch ?? postBody;
|
|
2146
|
-
const postBodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(postSessionScope, 'Stopped at');
|
|
2147
|
-
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
|
2148
|
-
// current_phase_name delta comparison.
|
|
2149
|
-
const postBodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(postBody, 'Phase');
|
|
2150
|
-
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
2151
|
-
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
2152
|
-
// in the STATE.md Transition Module. progress / status / stopped_at /
|
|
2153
|
-
// current_phase_name are all governed by their FIELD_CLASSIFICATION row —
|
|
2154
|
-
// one policy source, not three drifting encodings. Behavior-identical to
|
|
2155
|
-
// the pre-#1796 inline block; this is the absorption ADR-1769 / CONTEXT.md
|
|
2156
|
-
// already claimed shipped.
|
|
2157
|
-
const postFm = extractFrontmatter(synced, statePath);
|
|
2158
|
-
const preservation = applyStatePreservation({
|
|
2159
|
-
preFm, postFm, preFmSnapshot, resync,
|
|
3386
|
+
// #3469 (ADR-3408 §8.3): sync + post-sync preservation is the single
|
|
3387
|
+
// owned composition (`syncAndPreserveStateMd`), not assembled here — this
|
|
3388
|
+
// call site and `cmdPhaseComplete`'s atomic-commit adapter both route
|
|
3389
|
+
// through the same function so the composition cannot diverge between
|
|
3390
|
+
// the two.
|
|
3391
|
+
const synced = syncAndPreserveStateMd(content, modified, statePath, cwd, {
|
|
3392
|
+
resync,
|
|
3393
|
+
authoritativeFm: options?.authoritativeFm,
|
|
2160
3394
|
deriveProgressKeys: options?.deriveProgressKeys === true,
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
3395
|
+
divergedFields: options?.divergedFields,
|
|
3396
|
+
explicitProgressField: options?.explicitProgressField === true,
|
|
3397
|
+
// ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
|
|
3398
|
+
// fill it — an unenumerated option here is silently dropped
|
|
3399
|
+
// (Phase 1's commit message; #3871), which is exactly how a prior cut
|
|
3400
|
+
// of this option would have gone missing.
|
|
3401
|
+
preWriteState: options?.preWriteState,
|
|
2164
3402
|
});
|
|
2165
|
-
// #2736: re-assert the intent-first values AFTER preservation. On STATE.md
|
|
2166
|
-
// layouts with no body `Phase:` line, both phase-source snapshots are null
|
|
2167
|
-
// (equal), so the #1695 restore fires and would put the stale pre-transition
|
|
2168
|
-
// name back over the authoritative one. Intent beats both the prose
|
|
2169
|
-
// re-derivation and the curated restore — the transition just resolved it.
|
|
2170
|
-
let authoritativeReasserted = false;
|
|
2171
|
-
if (options?.authoritativeFm) {
|
|
2172
|
-
for (const [key, value] of Object.entries(options.authoritativeFm)) {
|
|
2173
|
-
if (typeof value === 'string' && value.trim().length > 0 && preservation.postFm[key] !== value) {
|
|
2174
|
-
preservation.postFm[key] = value;
|
|
2175
|
-
authoritativeReasserted = true;
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
if (preservation.mutated || authoritativeReasserted) {
|
|
2180
|
-
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
2181
|
-
const body = stripFrontmatter(synced);
|
|
2182
|
-
synced = `---\n${yamlStr}\n---\n\n${body}`;
|
|
2183
|
-
}
|
|
2184
3403
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
2185
3404
|
return true;
|
|
2186
3405
|
}
|
|
@@ -2188,6 +3407,438 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
2188
3407
|
releaseStateLock(lockPath);
|
|
2189
3408
|
}
|
|
2190
3409
|
}
|
|
3410
|
+
/**
|
|
3411
|
+
* ADR-3408 §8.4/§8.5 (D4): frontmatter field name → the body Title-Case
|
|
3412
|
+
* label the `updated` arrays below use. Every `preserve-when-unchanged` row
|
|
3413
|
+
* in `FIELD_CLASSIFICATION` MUST have an entry here (pinned by a parity test,
|
|
3414
|
+
* #3471 review) — `reconcileReportedFields` consults this so a preservation
|
|
3415
|
+
* event on `current_phase_name` folds into a report that otherwise only ever
|
|
3416
|
+
* speaks in body labels like `Current Phase Name` (#3345's direction). A
|
|
3417
|
+
* `preserve-when-unchanged` field missing here is a table drift bug and
|
|
3418
|
+
* `bodyLabelFor` throws rather than silently degrading to the raw
|
|
3419
|
+
* snake_case key (#3471 review — this is a second hand-maintained table
|
|
3420
|
+
* parallel to `FIELD_CLASSIFICATION`, so an unwired row must fail as loudly
|
|
3421
|
+
* as `throwUnwiredRow` in `state-transition.cts` does for the same shape of
|
|
3422
|
+
* omission). `preserve-always`/`preserve-if-placeholder` fields (`progress`,
|
|
3423
|
+
* `milestone`, `milestone_name`) are deliberately absent — `divergedFields`
|
|
3424
|
+
* (ADR-3408 §8.5's out-param) is NOT scoped to `preserve-when-unchanged`
|
|
3425
|
+
* rows alone (see `applyPostSyncPreservation`'s "regardless of which policy
|
|
3426
|
+
* executor fired" diff), so those fields legitimately reach the lookup with
|
|
3427
|
+
* no body-line label to report — `progress` is a structured sub-object and
|
|
3428
|
+
* `milestone`/`milestone_name` version/name pairs, neither ever rendered as
|
|
3429
|
+
* a body prose line — and `bodyLabelFor` falls through to the raw key for
|
|
3430
|
+
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
|
|
3431
|
+
* `divergedFields` reporting bare `'progress'`).
|
|
3432
|
+
*/
|
|
3433
|
+
/**
|
|
3434
|
+
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
|
3435
|
+
* (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key
|
|
3436
|
+
* order — the pre-#3873 literal's own order, which puts `status` AFTER
|
|
3437
|
+
* `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order
|
|
3438
|
+
* in `state-transition.cts`; the two pre-existing tables disagreed with each
|
|
3439
|
+
* other's order too, so each projection reproduces its OWN table's order
|
|
3440
|
+
* rather than a shared derivation). Byte-identical to the pre-#3873 literal:
|
|
3441
|
+
* same 7 keys, same order, same frozen (NOT null-prototype — this table was
|
|
3442
|
+
* a plain `Object.freeze({...})` literal before #3873 and stays one) shape.
|
|
3443
|
+
* `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s
|
|
3444
|
+
* `last_activity` row docstring for the resolved disagreement. Pinned by
|
|
3445
|
+
* `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and
|
|
3446
|
+
* `lastActivityLabelResolutionMatchesShippedBehavior`.
|
|
3447
|
+
*/
|
|
3448
|
+
const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([
|
|
3449
|
+
'current_phase',
|
|
3450
|
+
'current_phase_name',
|
|
3451
|
+
'current_plan',
|
|
3452
|
+
'stopped_at',
|
|
3453
|
+
'paused_at',
|
|
3454
|
+
'status',
|
|
3455
|
+
'last_activity_desc',
|
|
3456
|
+
]);
|
|
3457
|
+
const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze(FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => {
|
|
3458
|
+
const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key];
|
|
3459
|
+
if (row.bodyLabel !== undefined)
|
|
3460
|
+
acc[key] = row.bodyLabel;
|
|
3461
|
+
return acc;
|
|
3462
|
+
}, {}));
|
|
3463
|
+
/**
|
|
3464
|
+
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
|
|
3465
|
+
* entry. Throws for a `preserve-when-unchanged` field with no
|
|
3466
|
+
* `FRONTMATTER_KEY_TO_BODY_LABEL` row — that combination can only happen if
|
|
3467
|
+
* a future row is added to `FIELD_CLASSIFICATION` without a matching label,
|
|
3468
|
+
* an internal table-drift bug, never a user-document defect (mirrors
|
|
3469
|
+
* `throwUnwiredRow`'s shape in `state-transition.cts`: an `Error` carrying
|
|
3470
|
+
* `code` and `field` own-properties). Falls through to the raw field name
|
|
3471
|
+
* for every other policy (`preserve-always`, `preserve-if-placeholder`) —
|
|
3472
|
+
* those fields were never claimed to have a body-line label and reaching
|
|
3473
|
+
* this lookup with one of them is the documented, tested, working case
|
|
3474
|
+
* (e.g. `progress`), not a silent degrade.
|
|
3475
|
+
*/
|
|
3476
|
+
function bodyLabelFor(field) {
|
|
3477
|
+
// ADR-3473 §8.7 (#3872 review): an OWN-PROPERTY check, never a bare
|
|
3478
|
+
// bracket read — `FRONTMATTER_KEY_TO_BODY_LABEL` is a plain object literal
|
|
3479
|
+
// (real `Object.prototype` in its chain), so `[field]` for a hostile field
|
|
3480
|
+
// named `__proto__`/`constructor`/`toString` returns the INHERITED
|
|
3481
|
+
// prototype-chain member (`Object.prototype` itself, the `Object`
|
|
3482
|
+
// constructor function, `Object.prototype.toString`) instead of
|
|
3483
|
+
// `undefined` — which would then be returned as the "label" and leak a
|
|
3484
|
+
// non-string value into the caller's `updated` array. Proven by
|
|
3485
|
+
// `dottedResolutionDoesNotPollutePrototypes` (test matrix row 25) before
|
|
3486
|
+
// this fix. Mirrors `resolveFrontmatterPath`'s own-property discipline.
|
|
3487
|
+
if (Object.prototype.hasOwnProperty.call(FRONTMATTER_KEY_TO_BODY_LABEL, field)) {
|
|
3488
|
+
return FRONTMATTER_KEY_TO_BODY_LABEL[field];
|
|
3489
|
+
}
|
|
3490
|
+
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3491
|
+
if (cls && cls.preservation === 'preserve-when-unchanged') {
|
|
3492
|
+
const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
|
|
3493
|
+
'FRONTMATTER_KEY_TO_BODY_LABEL entry. This is an internal invariant violation (ADR-3408 ' +
|
|
3494
|
+
'§8.4/D4) — add a label for this field to FRONTMATTER_KEY_TO_BODY_LABEL.');
|
|
3495
|
+
err.code = 'STATE_BODY_LABEL_UNWIRED_ROW';
|
|
3496
|
+
err.field = field;
|
|
3497
|
+
throw err;
|
|
3498
|
+
}
|
|
3499
|
+
return field;
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* ADR-3473 §8.7 (issue #3872): the provenance exclusion — the ONLY
|
|
3503
|
+
* frontmatter key measured to change on EVERY write, regardless of content.
|
|
3504
|
+
* Verified at the CLI (`40-design.md` "Two corrections from reproducing it"):
|
|
3505
|
+
* two content-identical writes to a git-backed fixture differ in exactly
|
|
3506
|
+
* this one key. `state_head` was deliberately measured OUT of this set —
|
|
3507
|
+
* it restamps every write but its PERSISTED VALUE changes only when git HEAD
|
|
3508
|
+
* actually moved, so it tracks a real fact and does not flood.
|
|
3509
|
+
*
|
|
3510
|
+
* A CLOSED, ENUMERATED set — not a predicate or a callback (Greenspun's
|
|
3511
|
+
* Tenth Rule, ADR-3473 §8.7's Laws section: "the moment it takes a callback
|
|
3512
|
+
* it has become the classification table again under a new name"). It
|
|
3513
|
+
* exists to protect `src/state.cts:607` — `state.patch`'s ENTIRE
|
|
3514
|
+
* success/failure signal is `results.updated.length > 0` — admitting an
|
|
3515
|
+
* always-changing key here would make that boolean permanently `true`, so a
|
|
3516
|
+
* fully-failed patch would report success.
|
|
3517
|
+
*/
|
|
3518
|
+
const STATE_UPDATED_PROVENANCE_EXCLUSION = Object.freeze(['last_updated']);
|
|
3519
|
+
/** Sentinel: "this dotted path did not resolve to any value" — distinct from every real value including `undefined`/`null`, so absence and an explicit null are never confused. */
|
|
3520
|
+
const STATE_FIELD_ABSENT = Symbol('state-field-absent');
|
|
3521
|
+
/**
|
|
3522
|
+
* ADR-3473 §8.7 (#3872): resolve `path` against a parsed frontmatter object.
|
|
3523
|
+
* Pure, never throws.
|
|
3524
|
+
*
|
|
3525
|
+
* Order is pinned (test matrix row 26, `literalDottedKeyResolvesBeforePathTraversal`):
|
|
3526
|
+
* a LITERAL flat key wins first — a field name that happens to contain a `.`
|
|
3527
|
+
* but is stored as one flat key must not be shadowed by path traversal —
|
|
3528
|
+
* and only when no literal key exists does `path` get split and walked as a
|
|
3529
|
+
* dotted path.
|
|
3530
|
+
*
|
|
3531
|
+
* Hostile-input rows (23-25 of the test matrix) all resolve to
|
|
3532
|
+
* `STATE_FIELD_ABSENT` rather than throwing: a missing parent, a scalar
|
|
3533
|
+
* parent (`typeof cursor !== 'object'`), and — the prototype-pollution
|
|
3534
|
+
* case — a `__proto__`/`constructor`/`toString` segment. The own-property
|
|
3535
|
+
* check (`Object.prototype.hasOwnProperty.call`, never a bare `in` or
|
|
3536
|
+
* bracket read) is what makes the last one safe: an inherited
|
|
3537
|
+
* `Object.prototype` member is never mistaken for an own data key, and
|
|
3538
|
+
* because this function only ever READS a segment (never assigns one),
|
|
3539
|
+
* no prototype can be polluted by walking it.
|
|
3540
|
+
*/
|
|
3541
|
+
function resolveFrontmatterPath(fm, path) {
|
|
3542
|
+
if (Object.prototype.hasOwnProperty.call(fm, path))
|
|
3543
|
+
return fm[path];
|
|
3544
|
+
if (!path.includes('.'))
|
|
3545
|
+
return STATE_FIELD_ABSENT;
|
|
3546
|
+
let cursor = fm;
|
|
3547
|
+
for (const segment of path.split('.')) {
|
|
3548
|
+
if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor))
|
|
3549
|
+
return STATE_FIELD_ABSENT;
|
|
3550
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, segment))
|
|
3551
|
+
return STATE_FIELD_ABSENT;
|
|
3552
|
+
cursor = cursor[segment];
|
|
3553
|
+
}
|
|
3554
|
+
return cursor;
|
|
3555
|
+
}
|
|
3556
|
+
/**
|
|
3557
|
+
* ADR-3473 §8.7 (#3872): representation-insensitive equality for a
|
|
3558
|
+
* persisted-vs-snapshot leaf value (test matrix rows 21/22). Frontmatter
|
|
3559
|
+
* scalars round-trip as STRINGS (`extractFrontmatter`, §8.1's open type
|
|
3560
|
+
* question) while an in-memory derivation can hold a real number or boolean
|
|
3561
|
+
* — a naive `!==` would report every numeric/boolean field changed on every
|
|
3562
|
+
* write. Mirrors the existing `divergedFields` diff's typeof-object branch
|
|
3563
|
+
* in `applyPostSyncPreservation` (JSON.stringify for objects, else a
|
|
3564
|
+
* normalized scalar compare) rather than inventing a second comparison.
|
|
3565
|
+
* Presence-vs-absence (`STATE_FIELD_ABSENT` on exactly one side) is always a
|
|
3566
|
+
* change — a deleted or newly-added key (test matrix rows 16/17) — never
|
|
3567
|
+
* folded into the scalar branch below it.
|
|
3568
|
+
*/
|
|
3569
|
+
/**
|
|
3570
|
+
* ADR-3473 §8.7 (#3872): `String(v)` on an `unknown` is unsafe (a hostile
|
|
3571
|
+
* object could carry a custom, throwing, or `[object Object]`-degrading
|
|
3572
|
+
* `toString`) — narrowed per-branch here so each `String()` call below only
|
|
3573
|
+
* ever runs on a primitive TypeScript itself knows is safe to stringify.
|
|
3574
|
+
*/
|
|
3575
|
+
function stateScalarString(v) {
|
|
3576
|
+
if (v === null || v === undefined)
|
|
3577
|
+
return '';
|
|
3578
|
+
if (typeof v === 'string')
|
|
3579
|
+
return v;
|
|
3580
|
+
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
|
|
3581
|
+
return String(v);
|
|
3582
|
+
return JSON.stringify(v) ?? '';
|
|
3583
|
+
}
|
|
3584
|
+
function stateFieldValuesDiffer(before, after) {
|
|
3585
|
+
if (before === STATE_FIELD_ABSENT && after === STATE_FIELD_ABSENT)
|
|
3586
|
+
return false;
|
|
3587
|
+
if (before === STATE_FIELD_ABSENT || after === STATE_FIELD_ABSENT)
|
|
3588
|
+
return true;
|
|
3589
|
+
if (typeof before === 'object' || typeof after === 'object') {
|
|
3590
|
+
return JSON.stringify(before) !== JSON.stringify(after);
|
|
3591
|
+
}
|
|
3592
|
+
return stateScalarString(before).trim() !== stateScalarString(after).trim();
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* ADR-3473 §8.7 (#3872): the declared dotted-leaf children of a frontmatter
|
|
3596
|
+
* key, read off `FIELD_CLASSIFICATION` (`progress` -> its five
|
|
3597
|
+
* `progress.*` rows) rather than walked from arbitrary nesting depth of a
|
|
3598
|
+
* user-authored document. A BOUNDED, DECLARED enumeration — the design
|
|
3599
|
+
* doc's Rejected #5 and the "Emit dotted leaves, not the parent" rule both
|
|
3600
|
+
* depend on this staying a closed set the schema names, not unbounded
|
|
3601
|
+
* traversal of whatever object shape happens to be on disk.
|
|
3602
|
+
*/
|
|
3603
|
+
function declaredLeavesOf(key) {
|
|
3604
|
+
const prefix = `${key}.`;
|
|
3605
|
+
return Object.keys(FIELD_CLASSIFICATION).filter((k) => k.startsWith(prefix));
|
|
3606
|
+
}
|
|
3607
|
+
/**
|
|
3608
|
+
* ADR-3473 §8.7 (#3872): every frontmatter key — resolved at DOTTED-LEAF
|
|
3609
|
+
* granularity for a key with declared leaves (`progress` -> only the
|
|
3610
|
+
* `progress.*` leaves that actually moved, never bare `progress` itself;
|
|
3611
|
+
* design doc rule 4/Rejected #5) — whose PERSISTED value differs from the
|
|
3612
|
+
* transaction's pre-write SNAPSHOT. Pure: no I/O, no `FIELD_CLASSIFICATION`
|
|
3613
|
+
* preservation-policy consultation (that filter is exactly what this rule
|
|
3614
|
+
* deletes — ADR-3473 §8.7 "no field is excluded by classification").
|
|
3615
|
+
* `last_updated` is the one-element provenance exclusion; every other key,
|
|
3616
|
+
* including `state_head`, is a candidate.
|
|
3617
|
+
*
|
|
3618
|
+
* **A `FRONTMATTER_BODY_SOURCE` key is diffed via `bodyDeltas`, never via a
|
|
3619
|
+
* raw frontmatter compare.** Found while driving the #1264 regression check
|
|
3620
|
+
* through this rewrite at the CLI: `syncStateFrontmatter` re-derives EVERY
|
|
3621
|
+
* body-sourced key into frontmatter on EVERY write, independent of whether
|
|
3622
|
+
* this write's own transform touched it. A hand-authored (or day-1
|
|
3623
|
+
* bootstrap) STATE.md whose frontmatter has not yet caught up to an
|
|
3624
|
+
* already-stable body value — e.g. `current_phase_name` present in the body
|
|
3625
|
+
* but absent from a pre-write frontmatter block that only ever recorded
|
|
3626
|
+
* `status`/`progress` — makes that key look newly ADDED under a raw diff
|
|
3627
|
+
* (rows 15/17) even though nothing changed. The real "did THIS write change
|
|
3628
|
+
* it" signal for these keys is whether their BODY SOURCE moved, which is
|
|
3629
|
+
* exactly what `bodyDeltas` (built once, in `applyPostSyncPreservation`,
|
|
3630
|
+
* from `originalContent` vs `transformedContent`) already answers — reused
|
|
3631
|
+
* here rather than re-derived, and it is what correctly REPORTS #3818's
|
|
3632
|
+
* `current_phase` (the body source did move) while staying SILENT on a
|
|
3633
|
+
* merely-backfilled, body-unchanged key (the #1264 false positive this
|
|
3634
|
+
* function's first cut produced).
|
|
3635
|
+
*
|
|
3636
|
+
* **A declared dotted-leaf (`declaredLeavesOf`, e.g. every `progress.*` row)
|
|
3637
|
+
* absent from the snapshot and present in persisted is materialization, not
|
|
3638
|
+
* a change.** Found the same way as the paragraph above, one layer down:
|
|
3639
|
+
* `progress` is `source: 'disk'` (state-transition.cts), re-derived by
|
|
3640
|
+
* `buildStateFrontmatter`'s phase-directory scan on every write regardless
|
|
3641
|
+
* of whether the caller's own action touched it — and the phases directory
|
|
3642
|
+
* cannot move during a STATE.md write, so a fresh `progress` block appearing
|
|
3643
|
+
* where the snapshot had none is the scanner catching a never-synced
|
|
3644
|
+
* document up, not the caller changing anything. This is the SAME
|
|
3645
|
+
* provenance principle `STATE_UPDATED_PROVENANCE_EXCLUSION` applies to
|
|
3646
|
+
* `last_updated` (a field stamped by the write's occurrence, not its
|
|
3647
|
+
* action) — generalized to the declared-leaf case, deliberately NOT a
|
|
3648
|
+
* second classification-based exclusion: `progress`'s `preserve-always`
|
|
3649
|
+
* policy plays no part in the check below, and a leaf already PRESENT in
|
|
3650
|
+
* the snapshot is diffed exactly as every other field is, including
|
|
3651
|
+
* reporting its outright disappearance (row 16) — only the absent-in-
|
|
3652
|
+
* snapshot-but-materialized-in-persisted transition is suppressed.
|
|
3653
|
+
*/
|
|
3654
|
+
function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
|
|
3655
|
+
const changed = [];
|
|
3656
|
+
const topKeys = new Set([...Object.keys(snapshotFm), ...Object.keys(persistedFm)]);
|
|
3657
|
+
for (const key of topKeys) {
|
|
3658
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(key))
|
|
3659
|
+
continue;
|
|
3660
|
+
if (stateTransitionMod.getFrontmatterBodySource(key) !== null) {
|
|
3661
|
+
const delta = bodyDeltas ? bodyDeltas[key] : undefined;
|
|
3662
|
+
if (delta && stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT)) {
|
|
3663
|
+
changed.push(key);
|
|
3664
|
+
}
|
|
3665
|
+
continue;
|
|
3666
|
+
}
|
|
3667
|
+
const leaves = declaredLeavesOf(key);
|
|
3668
|
+
if (leaves.length > 0) {
|
|
3669
|
+
for (const leaf of leaves) {
|
|
3670
|
+
const before = resolveFrontmatterPath(snapshotFm, leaf);
|
|
3671
|
+
const after = resolveFrontmatterPath(persistedFm, leaf);
|
|
3672
|
+
// Generalizes the SAME provenance principle STATE_UPDATED_PROVENANCE_EXCLUSION
|
|
3673
|
+
// applies to `last_updated` one level up — this is NOT a classification-based
|
|
3674
|
+
// exclusion (progress's `preserve-always` policy plays no part here; that filter
|
|
3675
|
+
// stays deleted per §8.7). It is a fact about the DECLARED LEAF SET: every key
|
|
3676
|
+
// enumerated by `declaredLeavesOf` is `source: 'disk'` (state-transition.cts),
|
|
3677
|
+
// re-derived from a scan that cannot move during a STATE.md write (the write only
|
|
3678
|
+
// touches STATE.md, never the phases directory). So a leaf ABSENT from the
|
|
3679
|
+
// pre-write snapshot and PRESENT in persisted is the scanner catching a document
|
|
3680
|
+
// up to a derivation it had never synced before — the write's own OCCURRENCE
|
|
3681
|
+
// produced the bytes, not the caller's ACTION, exactly the `last_updated` shape.
|
|
3682
|
+
// A leaf already PRESENT in the snapshot behaves normally: any difference
|
|
3683
|
+
// (including disappearing entirely, row 16) is reported, because there the
|
|
3684
|
+
// snapshot proves the derivation had already run once, so a new persisted value
|
|
3685
|
+
// can only come from something genuinely moving (#3743/#3818).
|
|
3686
|
+
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
3687
|
+
continue;
|
|
3688
|
+
if (stateFieldValuesDiffer(before, after))
|
|
3689
|
+
changed.push(leaf);
|
|
3690
|
+
}
|
|
3691
|
+
continue;
|
|
3692
|
+
}
|
|
3693
|
+
const before = resolveFrontmatterPath(snapshotFm, key);
|
|
3694
|
+
const after = resolveFrontmatterPath(persistedFm, key);
|
|
3695
|
+
if (stateFieldValuesDiffer(before, after))
|
|
3696
|
+
changed.push(key);
|
|
3697
|
+
}
|
|
3698
|
+
return changed;
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* ADR-3473 §8.7 (issue #3872): the transaction diff. `updated` is derived
|
|
3702
|
+
* by comparing PERSISTED frontmatter against the transaction's pre-write
|
|
3703
|
+
* SNAPSHOT — replacing the prior comparison of the transform's own OUTPUT
|
|
3704
|
+
* against persisted bytes, which answered a different question ("did the
|
|
3705
|
+
* transform's write survive to disk", #3351) from the one §8.7 asks ("what
|
|
3706
|
+
* did this write actually change" — both #3351's direction and #3345/#3818's
|
|
3707
|
+
* fall out of ONE comparison against the pre-write state; see the design
|
|
3708
|
+
* doc's "ambiguity in §8.7" section for why the transform-output comparison
|
|
3709
|
+
* was rejected).
|
|
3710
|
+
*
|
|
3711
|
+
* No field is excluded by classification — `getFieldClassification` /
|
|
3712
|
+
* `preservation !== 'preserve-when-unchanged'` is gone, not relocated. The
|
|
3713
|
+
* ONLY exclusion is `STATE_UPDATED_PROVENANCE_EXCLUSION` (provenance, not
|
|
3714
|
+
* classification): an unchanged `progress` no longer needs a special filter
|
|
3715
|
+
* to stay unreported (#1264) because the diff itself says "unchanged" —
|
|
3716
|
+
* and a GENUINELY changed `progress.*` leaf (#3743, #3818) is no longer
|
|
3717
|
+
* suppressed by the same filter.
|
|
3718
|
+
*
|
|
3719
|
+
* @param preWriteState The transaction's pre-write snapshot + body — the
|
|
3720
|
+
* `preWriteState` out-param `applyPostSyncPreservation` filled during
|
|
3721
|
+
* THIS write (see `ReadModifyWriteOptions.preWriteState`'s docstring).
|
|
3722
|
+
* `.fm`/`.body` are `undefined` only when `readModifyWriteStateMd`'s own
|
|
3723
|
+
* #948 no-op guard fired (transform output was byte-identical to input),
|
|
3724
|
+
* in which case nothing was ever written and `[]` is the correct,
|
|
3725
|
+
* short-circuited answer — never a diff against a synthesized empty `{}`
|
|
3726
|
+
* snapshot, which would read every already-persisted key as newly ADDED.
|
|
3727
|
+
* @param reported The candidate field names — the transform's OWN success
|
|
3728
|
+
* list. Body Title-Case labels (`Status`, `Current Plan`, `Current
|
|
3729
|
+
* Position`) and frontmatter keys (including dotted leaves like
|
|
3730
|
+
* `progress.total_plans`) are both valid; each is resolved via the same
|
|
3731
|
+
* `valueOf` fallback chain used for the inclusion test below.
|
|
3732
|
+
* @param divergedFields Kept for signature/out-param stability (ADR-3408
|
|
3733
|
+
* §8.5) — populated exactly as before by `applyPostSyncPreservation` and
|
|
3734
|
+
* still read directly by other code and `tests/state.test.cjs`'s A2f case
|
|
3735
|
+
* — but no longer consulted here as a candidate SOURCE (design doc row
|
|
3736
|
+
* 18): the frontmatter diff subsumes what it used to contribute, and it
|
|
3737
|
+
* sees only what *preservation* changed, never what *sync* changed
|
|
3738
|
+
* (#3818's own direction), which is why keeping it as the candidate
|
|
3739
|
+
* source was rejected (design doc, Rejected #1).
|
|
3740
|
+
*/
|
|
3741
|
+
function reconcileReportedFields(statePath, preWriteState, reported, divergedFields) {
|
|
3742
|
+
void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
|
|
3743
|
+
if (preWriteState.fm === undefined || preWriteState.body === undefined)
|
|
3744
|
+
return [];
|
|
3745
|
+
const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
3746
|
+
const persistedFm = extractFrontmatter(persisted, statePath);
|
|
3747
|
+
const persistedBody = stripFrontmatter(persisted);
|
|
3748
|
+
const snapshotFm = preWriteState.fm;
|
|
3749
|
+
const snapshotBody = preWriteState.body;
|
|
3750
|
+
// #3471 review (unchanged by this rewrite): body-FIRST, frontmatter-key-
|
|
3751
|
+
// FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
|
|
3752
|
+
// write precedence `patchCore`/`updateCore` apply (#1162's fix — a
|
|
3753
|
+
// lowercase body label that happens to case-exact-match a frontmatter key
|
|
3754
|
+
// must still resolve against the body). `field` a literal flat key (even
|
|
3755
|
+
// one containing a `.`) is tried before it is split and walked as a
|
|
3756
|
+
// dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
|
|
3757
|
+
// same order for the frontmatter side alone.
|
|
3758
|
+
//
|
|
3759
|
+
// `Current Position` is special-cased: it names the WHOLE `## Current
|
|
3760
|
+
// Position` section, not a single `Label: value` line, so
|
|
3761
|
+
// `stateExtractField` can never resolve it (this is the root cause of the
|
|
3762
|
+
// "Current Position undercount" — a transform can correctly push
|
|
3763
|
+
// `'Current Position'` into its own `updated` list, and this function
|
|
3764
|
+
// still silently dropped it, because `valueOf` returned `null` for BOTH
|
|
3765
|
+
// sides and `null === null` failed the old `intended !== null` guard).
|
|
3766
|
+
// `sliceCurrentPositionSection` is the existing fence-aware section
|
|
3767
|
+
// locator (state-transition.cts) — reused rather than re-derived.
|
|
3768
|
+
const valueOf = (fm, body, field) => {
|
|
3769
|
+
if (field === 'Current Position') {
|
|
3770
|
+
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
3771
|
+
return section !== null ? section.trim() : null;
|
|
3772
|
+
}
|
|
3773
|
+
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
3774
|
+
if (bodyValue !== null)
|
|
3775
|
+
return bodyValue;
|
|
3776
|
+
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
3777
|
+
return String(fm[field]);
|
|
3778
|
+
if (field.includes('.')) {
|
|
3779
|
+
const resolved = resolveFrontmatterPath(fm, field);
|
|
3780
|
+
if (resolved !== STATE_FIELD_ABSENT) {
|
|
3781
|
+
return stateScalarString(resolved);
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
return null;
|
|
3785
|
+
};
|
|
3786
|
+
// A field in `reported` can itself be a declared derived leaf (e.g.
|
|
3787
|
+
// `plannedPhaseCore` pushing `'progress.total_plans'` — state-
|
|
3788
|
+
// transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
|
|
3789
|
+
// "absent from the frontmatter" apart from "resolved to the literal string
|
|
3790
|
+
// 'null'/''", so it cannot carry the same materialization rule
|
|
3791
|
+
// `computeChangedFrontmatterFields` applies below. Route these fields
|
|
3792
|
+
// through the SAME primitives (`resolveFrontmatterPath` + the
|
|
3793
|
+
// `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
3794
|
+
// second, parallel absence convention — one rule, reused, not duplicated.
|
|
3795
|
+
const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
|
|
3796
|
+
const changed = (field) => {
|
|
3797
|
+
if (isDeclaredDerivedLeaf(field)) {
|
|
3798
|
+
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
3799
|
+
const after = resolveFrontmatterPath(persistedFm, field);
|
|
3800
|
+
// Same generalized provenance rule as computeChangedFrontmatterFields:
|
|
3801
|
+
// absent-in-snapshot-materializing-in-persisted is the disk scan
|
|
3802
|
+
// catching a never-synced document up, not this write's own action.
|
|
3803
|
+
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
3804
|
+
return false;
|
|
3805
|
+
return stateFieldValuesDiffer(before, after);
|
|
3806
|
+
}
|
|
3807
|
+
const before = valueOf(snapshotFm, snapshotBody, field);
|
|
3808
|
+
const after = valueOf(persistedFm, persistedBody, field);
|
|
3809
|
+
if (before === null && after === null)
|
|
3810
|
+
return false;
|
|
3811
|
+
if (before === null || after === null)
|
|
3812
|
+
return true;
|
|
3813
|
+
return before.trim() !== after.trim();
|
|
3814
|
+
};
|
|
3815
|
+
// Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
|
|
3816
|
+
// granularity) whose persisted value differs from the snapshot, minus the
|
|
3817
|
+
// provenance exclusion. A frontmatter-diff-discovered field is mapped
|
|
3818
|
+
// through `bodyLabelFor` so it lands in the SAME output vocabulary a
|
|
3819
|
+
// transform would have used (`status` -> `'Status'`; `progress.total_plans`
|
|
3820
|
+
// has no body-line label and falls through to its raw dotted key, same as
|
|
3821
|
+
// today's `progress`/`milestone*` fall-through).
|
|
3822
|
+
const changedFrontmatterFields = computeChangedFrontmatterFields(snapshotFm, persistedFm, preWriteState.bodyDeltas);
|
|
3823
|
+
const mappedFrontmatterFields = changedFrontmatterFields.map((field) => bodyLabelFor(field));
|
|
3824
|
+
const seen = new Set();
|
|
3825
|
+
const reconciled = [];
|
|
3826
|
+
for (const field of reported) {
|
|
3827
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
|
|
3828
|
+
continue;
|
|
3829
|
+
if (changed(field)) {
|
|
3830
|
+
seen.add(field);
|
|
3831
|
+
reconciled.push(field);
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
for (const field of mappedFrontmatterFields) {
|
|
3835
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
|
|
3836
|
+
continue;
|
|
3837
|
+
seen.add(field);
|
|
3838
|
+
reconciled.push(field);
|
|
3839
|
+
}
|
|
3840
|
+
return reconciled;
|
|
3841
|
+
}
|
|
2191
3842
|
function cmdStateJson(cwd, raw) {
|
|
2192
3843
|
const statePath = planningPaths(cwd).state;
|
|
2193
3844
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -2200,28 +3851,82 @@ function cmdStateJson(cwd, raw) {
|
|
|
2200
3851
|
// Always rebuild from body + disk so progress counters reflect current state.
|
|
2201
3852
|
// Returning cached frontmatter directly causes stale percent/completed_plans
|
|
2202
3853
|
// when SUMMARY files were added after the last STATE.md write (#1589).
|
|
2203
|
-
|
|
2204
|
-
//
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
//
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
3854
|
+
// #3354: pass the stored total so the milestoned-but-unbounded withhold can
|
|
3855
|
+
// report the preserved value instead of omitting the key.
|
|
3856
|
+
// #3573: pass the STORED MILESTONE too (same parity reasoning) — otherwise the
|
|
3857
|
+
// roadmap-absent withhold never fires on this read surface and `state json`
|
|
3858
|
+
// reports the phase-directory count while the persisted file preserves the
|
|
3859
|
+
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
|
3860
|
+
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
3861
|
+
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
|
|
3862
|
+
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
|
3863
|
+
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
|
3864
|
+
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
|
3865
|
+
// third private copy of the empty-only guards with no delta/staleness check
|
|
3866
|
+
// at all — the shape that let a stale-but-present body annotation always
|
|
3867
|
+
// beat a fresher curated frontmatter value in `state json` output (#3395's
|
|
3868
|
+
// shape outside the write seam).
|
|
3869
|
+
//
|
|
3870
|
+
// `cmdStateJson` never writes — it is one snapshot read, not a
|
|
3871
|
+
// before/after transform — so "did THIS write change the body source"
|
|
3872
|
+
// (the #1230 delta the executor consults) is definitionally "no": every
|
|
3873
|
+
// field's body source is passed as its own delta pre/post pair (the same
|
|
3874
|
+
// value twice). That is what makes the executor's rule resolve to
|
|
3875
|
+
// "restore the curated value whenever a real one exists" here — exactly
|
|
3876
|
+
// §8.5's "same terms as an empty derived value" extended to a present
|
|
3877
|
+
// one, i.e. the exact D3 fix. Deliberately scoped to only these six
|
|
3878
|
+
// fields (not the full `applyStatePreservation` dispatch loop): `progress`
|
|
3879
|
+
// (preserve-always) keeps its own `shouldPreserveExistingProgress`
|
|
3880
|
+
// cross-milestone rule below — a DIFFERENT policy that must survive this
|
|
3881
|
+
// change untouched — and `milestone`/`milestone_name`
|
|
3882
|
+
// (preserve-if-placeholder) are out of D3's scope entirely.
|
|
3883
|
+
if (existingFm) {
|
|
3884
|
+
const sessionScope = matchSessionSection(body) ?? body;
|
|
3885
|
+
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
3886
|
+
const bodyStoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Stopped at');
|
|
3887
|
+
const bodyPausedAt = (0, state_document_cjs_1.stateExtractField)(sessionScope, 'Paused At');
|
|
3888
|
+
const bodyPhaseSource = (0, state_document_cjs_1.stateExtractField)(body, 'Phase');
|
|
3889
|
+
const bodyCurrentPhase = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase')
|
|
3890
|
+
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
3891
|
+
const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
3892
|
+
const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
|
|
3893
|
+
// #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
|
|
3894
|
+
// bodyDeltas, `last_activity_desc`) — the `Last Activity Description`
|
|
3895
|
+
// label, falling back to the prose `Last Activity:` line's parsed
|
|
3896
|
+
// description. Read-side twin of #3258's write-side wiring; this field is
|
|
3897
|
+
// `preserve-when-unchanged` per FIELD_CLASSIFICATION and was previously
|
|
3898
|
+
// absent from this read path entirely (never derived here, never in the
|
|
3899
|
+
// loop below), so a stale body annotation always beat a fresher curated
|
|
3900
|
+
// frontmatter value on every `state json` read.
|
|
3901
|
+
const bodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
|
|
3902
|
+
const bodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description')
|
|
3903
|
+
?? parseProseLastActivityField(bodyLastActivityRaw).description;
|
|
3904
|
+
const unchanged = (v) => ({ pre: v, post: v });
|
|
3905
|
+
const ctx = {
|
|
3906
|
+
postFm: built,
|
|
3907
|
+
snapshot: existingFm,
|
|
3908
|
+
resync: true,
|
|
3909
|
+
deriveProgressKeys: false,
|
|
3910
|
+
bodyDeltas: {
|
|
3911
|
+
status: unchanged(bodyStatus),
|
|
3912
|
+
stopped_at: unchanged(bodyStoppedAt),
|
|
3913
|
+
paused_at: unchanged(bodyPausedAt),
|
|
3914
|
+
current_phase: unchanged(bodyCurrentPhase),
|
|
3915
|
+
current_plan: unchanged(bodyCurrentPlan),
|
|
3916
|
+
current_phase_name: unchanged(bodyPhaseSource),
|
|
3917
|
+
last_activity_desc: unchanged(bodyLastActivityDesc),
|
|
3918
|
+
},
|
|
3919
|
+
mutated: false,
|
|
3920
|
+
};
|
|
3921
|
+
// #3836: derive the field set from FIELD_CLASSIFICATION's
|
|
3922
|
+
// `preserve-when-unchanged` rows (single source of truth) instead of a
|
|
3923
|
+
// hand-typed literal that can drift from the table — this IS the fix,
|
|
3924
|
+
// not merely an addition of one more name to the literal.
|
|
3925
|
+
for (const field of stateTransitionMod.getPreserveWhenUnchangedFields()) {
|
|
3926
|
+
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3927
|
+
if (cls)
|
|
3928
|
+
stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
|
|
3929
|
+
}
|
|
2225
3930
|
}
|
|
2226
3931
|
// Preserve curated cross-milestone aggregates when local disk scanning sees
|
|
2227
3932
|
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
|
|
@@ -2269,13 +3974,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
2269
3974
|
// that itself contains a parenthetical. The #1695 delta-gate preservation
|
|
2270
3975
|
// still runs after the sync; the override is re-asserted after it inside
|
|
2271
3976
|
// readModifyWriteStateMd for layouts with no body `Phase:` line.
|
|
3977
|
+
const divergedFields = [];
|
|
3978
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
3979
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
3980
|
+
const preWriteState = {};
|
|
2272
3981
|
const rmwOptions = {
|
|
2273
3982
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3983
|
+
divergedFields,
|
|
3984
|
+
preWriteState,
|
|
2274
3985
|
};
|
|
2275
|
-
let
|
|
2276
|
-
|
|
3986
|
+
let precomputedUpdated = [];
|
|
3987
|
+
// #3311: begin-phase is the claim point — it is the one Current Position
|
|
3988
|
+
// transition that explicitly names its phase, so it both records this
|
|
3989
|
+
// session's claim and detects a conflicting live claim for a different
|
|
3990
|
+
// phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
|
|
3991
|
+
// calls cannot both read "no claim" and both write.
|
|
3992
|
+
let milestoneConflict = null;
|
|
3993
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
3994
|
+
milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
|
|
3995
|
+
if (milestoneConflict) {
|
|
3996
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
|
|
3997
|
+
}
|
|
2277
3998
|
const result = transitionCore(content, intent, deps);
|
|
2278
|
-
|
|
3999
|
+
precomputedUpdated = result.updated;
|
|
2279
4000
|
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
|
|
2280
4001
|
// the intent-first override must not fire — it would drift frontmatter
|
|
2281
4002
|
// away from the preserved body value. Dropping it here is safe because
|
|
@@ -2285,7 +4006,33 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
2285
4006
|
}
|
|
2286
4007
|
return result.content;
|
|
2287
4008
|
}, cwd, rmwOptions);
|
|
2288
|
-
|
|
4009
|
+
// ADR-3408 §8.4 (D4): reconcile `beginPhaseCore`'s own success list against
|
|
4010
|
+
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
|
4011
|
+
// field preservation restored that this transform never touched (#3345's
|
|
4012
|
+
// direction).
|
|
4013
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
4014
|
+
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
|
|
4015
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on `wrote`
|
|
4016
|
+
// (readModifyWriteStateMd's own return value — its #948 no-op guard skips
|
|
4017
|
+
// the write outright when the transform produced no diff), not on
|
|
4018
|
+
// `updated.length > 0`. Confirmed reproducer: an unrecognized-format
|
|
4019
|
+
// STATE.md makes `beginPhaseCore` match zero body fields AND leave
|
|
4020
|
+
// `existingFm` untouched, so the raw transform output is byte-identical to
|
|
4021
|
+
// the input, the RMW guard fires, and `wrote` is false — matching
|
|
4022
|
+
// `updated: []` here. Unlike `cmdStatePlannedPhase` (which must NOT use
|
|
4023
|
+
// this same `wrote` signal — see its comment for why `plannedPhaseCore`
|
|
4024
|
+
// mutates frontmatter in place even on this exact no-op shape),
|
|
4025
|
+
// `beginPhaseCore` never mutates `existingFm`, so `wrote` and
|
|
4026
|
+
// `updated.length > 0` agree on every case audited for this phase; `wrote`
|
|
4027
|
+
// is kept as the gate here (and on `cmdStateAdvancePlan`/
|
|
4028
|
+
// `cmdStateCompletePhase` below, where it is REQUIRED — `updated`/
|
|
4029
|
+
// `reconciled` can be non-empty there even when nothing was written,
|
|
4030
|
+
// confirmed by direct re-invocation) for one consistent rule across every
|
|
4031
|
+
// RMW-backed command in this file: publish iff `readModifyWriteStateMd`
|
|
4032
|
+
// itself reports a write. Best-effort — cannot throw, cannot change this
|
|
4033
|
+
// command's exit code or output.
|
|
4034
|
+
if (wrote)
|
|
4035
|
+
publishStateContract(cwd);
|
|
2289
4036
|
}
|
|
2290
4037
|
/**
|
|
2291
4038
|
* Write a WAITING.json signal file when GSD hits a decision point.
|
|
@@ -2392,7 +4139,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
|
|
|
2392
4139
|
// direction (#1659): canonicalize a numeric phase to its integer form so a seeded
|
|
2393
4140
|
// "| 05 |" row is upserted (not duplicated) by `phase complete 5`, and vice-versa.
|
|
2394
4141
|
const phaseNumStr = String(phaseNum);
|
|
2395
|
-
const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : escapeRegex(phaseNumStr);
|
|
4142
|
+
const canonCell = /^\d+$/.test(phaseNumStr) ? `0*${Number(phaseNumStr)}` : (0, pattern_cjs_1.escapeRegex)(phaseNumStr);
|
|
2396
4143
|
const phaseCellRe = new RegExp(`^${canonCell}$`, 'i');
|
|
2397
4144
|
const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
|
|
2398
4145
|
const before = content.slice(0, tableStart);
|
|
@@ -2508,7 +4255,7 @@ function updatePerformanceMetricsSection(content, cwd, phaseNum, planCount, summ
|
|
|
2508
4255
|
* Gate 3a: Record state after plan-phase completes.
|
|
2509
4256
|
* Updates Status to "Ready to execute", Total Plans, Last Activity.
|
|
2510
4257
|
*/
|
|
2511
|
-
function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
4258
|
+
function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
2512
4259
|
const statePath = planningPaths(cwd).state;
|
|
2513
4260
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
2514
4261
|
output({ error: 'STATE.md not found' }, raw, undefined);
|
|
@@ -2525,22 +4272,87 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
|
|
2525
4272
|
const intent = {
|
|
2526
4273
|
kind: 'plannedPhase',
|
|
2527
4274
|
phaseNumber,
|
|
4275
|
+
phaseName: phaseName ?? null,
|
|
2528
4276
|
planCount: planCount ?? null,
|
|
2529
4277
|
};
|
|
2530
4278
|
const deps = {
|
|
2531
4279
|
clock: clock_cjs_1.realClock,
|
|
2532
4280
|
sourcePath: statePath,
|
|
2533
4281
|
};
|
|
2534
|
-
|
|
4282
|
+
// #3395 / #2736: the transition holds the exact display name. plannedPhaseCore
|
|
4283
|
+
// writes it into the Current Position `Phase: N (Name) — READY TO EXECUTE`
|
|
4284
|
+
// line, and the prose re-derivation of current_phase_name truncates names
|
|
4285
|
+
// that themselves contain a parenthetical — the authoritative override keeps
|
|
4286
|
+
// the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
|
|
4287
|
+
//
|
|
4288
|
+
// #3834: without a name, the body-source delta rule that would normally
|
|
4289
|
+
// preserve the curated `current_phase_name` (FIELD_CLASSIFICATION:
|
|
4290
|
+
// preserve-when-unchanged) cannot fire — THIS write rewrites the `Phase:`
|
|
4291
|
+
// source line to `N — READY TO EXECUTE` itself, so pre/post disagree by
|
|
4292
|
+
// construction and the post-sync re-derivation harvests "READY TO EXECUTE"
|
|
4293
|
+
// as if it were the name. The fix mirrors the named-arg path: reassert an
|
|
4294
|
+
// authoritative override, falling back to the pre-write curated value (read
|
|
4295
|
+
// inside the RMW callback, before this write's own body mutation) rather
|
|
4296
|
+
// than leaving the field to a delta heuristic this exact transition defeats.
|
|
4297
|
+
const divergedFields = [];
|
|
4298
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
4299
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
4300
|
+
const preWriteState = {};
|
|
4301
|
+
const rmwOptions = {
|
|
4302
|
+
resync: false,
|
|
4303
|
+
deriveProgressKeys: true,
|
|
4304
|
+
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
4305
|
+
divergedFields,
|
|
4306
|
+
preWriteState,
|
|
4307
|
+
};
|
|
4308
|
+
let precomputedUpdated = [];
|
|
2535
4309
|
readModifyWriteStateMd(statePath, (content) => {
|
|
4310
|
+
if (!intent.phaseName) {
|
|
4311
|
+
const preFm = extractFrontmatter(content, statePath);
|
|
4312
|
+
const curatedName = preFm['current_phase_name'];
|
|
4313
|
+
if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
|
|
4314
|
+
rmwOptions.authoritativeFm = { current_phase_name: curatedName };
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
2536
4317
|
const result = transitionCore(content, intent, deps);
|
|
2537
|
-
|
|
4318
|
+
precomputedUpdated = result.updated;
|
|
2538
4319
|
return result.content;
|
|
2539
|
-
}, cwd,
|
|
4320
|
+
}, cwd, rmwOptions);
|
|
4321
|
+
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
|
4322
|
+
// against the bytes actually persisted (fix(#3351) generalized) and fold
|
|
4323
|
+
// in any field preservation restored that this transform never touched
|
|
4324
|
+
// (#3345's direction) — traced for this phase (design doc: "not traced in
|
|
4325
|
+
// the analysis pass") and found to need exactly the same treatment as
|
|
4326
|
+
// `cmdStateBeginPhase`.
|
|
4327
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
2540
4328
|
const result = updated.length === 0
|
|
2541
4329
|
? { 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.).' }
|
|
2542
4330
|
: { updated, phase: phaseNumber, plan_count: planCount };
|
|
2543
4331
|
output(result, raw, updated.length > 0 ? 'true' : 'false');
|
|
4332
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on
|
|
4333
|
+
// `updated.length > 0`, NOT on `readModifyWriteStateMd`'s own write-happened
|
|
4334
|
+
// return value. The two are NOT equivalent here: readModifyWriteStateMd's
|
|
4335
|
+
// #948 no-op guard compares the transform's RAW returned string against the
|
|
4336
|
+
// RAW original file content, but `syncStateFrontmatter`'s progress-block
|
|
4337
|
+
// sync and this command's `authoritativeFm: {current_phase_name}` override
|
|
4338
|
+
// both run INSIDE the transform (via `frontmatterMod.reconstructFrontmatter`
|
|
4339
|
+
// over `existingFm`), so an unrecognized-format STATE.md — zero fields the
|
|
4340
|
+
// transition could actually apply, `updated: []`, the "transition was a
|
|
4341
|
+
// no-op" warning above — can still make the raw returned string differ
|
|
4342
|
+
// from the input (frontmatter gets synthesized: `gsd_state_version`,
|
|
4343
|
+
// `last_updated`, a zeroed `progress` block, `current_phase_name`), so the
|
|
4344
|
+
// RMW guard does NOT fire and a real write happens. That write is not a
|
|
4345
|
+
// meaningful state transition by this command's OWN reporting contract
|
|
4346
|
+
// (`updated: []`) — publishing on it would refresh state.json's
|
|
4347
|
+
// `updated_at` for a call this command itself reports did nothing.
|
|
4348
|
+
// `updated.length > 0` is the field-classification-table-backed signal
|
|
4349
|
+
// that actually answers "did plannedPhaseCore itself change anything this
|
|
4350
|
+
// caller asked it to change" — empirically verified: an unrecognized-format
|
|
4351
|
+
// STATE.md reproduces `updated: []` with a genuine (frontmatter-only) disk
|
|
4352
|
+
// write underneath it, and gating on `updated.length > 0` is what makes
|
|
4353
|
+
// this reproducer NOT publish.
|
|
4354
|
+
if (updated.length > 0)
|
|
4355
|
+
publishStateContract(cwd);
|
|
2544
4356
|
}
|
|
2545
4357
|
/**
|
|
2546
4358
|
* Bug #2630: reset STATE.md for a new milestone cycle.
|
|
@@ -2563,25 +4375,144 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
2563
4375
|
// steady-state syncStateFrontmatter post-sync.
|
|
2564
4376
|
const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
|
|
2565
4377
|
const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
|
|
4378
|
+
let switched = false;
|
|
2566
4379
|
const lockPath = acquireStateLock(statePath);
|
|
2567
4380
|
try {
|
|
2568
4381
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
2569
4382
|
const result = transitionCore(content, intent, deps);
|
|
2570
4383
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
|
|
2571
4384
|
output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
|
|
4385
|
+
switched = true;
|
|
2572
4386
|
}
|
|
2573
4387
|
finally {
|
|
2574
4388
|
releaseStateLock(lockPath);
|
|
2575
4389
|
}
|
|
4390
|
+
// #3227: publish AFTER releaseStateLock — publishStateContract derives `next`
|
|
4391
|
+
// from classifyProject, which shells out to git (bounded, but up to 3 x 10s).
|
|
4392
|
+
// Holding the STATE.md lock across that would turn a millisecond hold into a
|
|
4393
|
+
// git-bound one for every concurrent GSD process.
|
|
4394
|
+
if (switched)
|
|
4395
|
+
publishStateContract(cwd);
|
|
2576
4396
|
}
|
|
2577
4397
|
/**
|
|
2578
4398
|
* Gate 1: Validate STATE.md against filesystem.
|
|
2579
|
-
* Returns { valid, warnings, drift } JSON.
|
|
4399
|
+
* Returns { valid, warnings, drift, scope } JSON.
|
|
4400
|
+
*
|
|
4401
|
+
* #3187 (ADR-3180 §7.7, Decisions 2-4): two defects fixed here.
|
|
4402
|
+
*
|
|
4403
|
+
* (1) #3162 THE HEADLINE. Every warning this function can emit used to be
|
|
4404
|
+
* gated behind `if (currentPhase && fs.existsSync(phasesDir))`, and
|
|
4405
|
+
* `currentPhase` came from a body-only `stateExtractField(content, 'Current
|
|
4406
|
+
* Phase')` call with no frontmatter fallback. A STATE.md whose phase lives
|
|
4407
|
+
* ONLY in frontmatter therefore resolved `currentPhase` to `null`, the whole
|
|
4408
|
+
* drift block was skipped, and the function returned
|
|
4409
|
+
* `{valid:true, warnings:[], drift:{}}` — "could not look" was
|
|
4410
|
+
* output-identical to "looked, all clean." Current Phase / Status / Total
|
|
4411
|
+
* Plans in Phase now route through `stateFieldValue` (the single owner of the
|
|
4412
|
+
* #1760 frontmatter-then-body fallback chain), so the frontmatter tier is
|
|
4413
|
+
* actually consulted.
|
|
4414
|
+
*
|
|
4415
|
+
* (2) #1255 FRONTMATTER SHADOWING. The old code passed UNSTRIPPED `content`
|
|
4416
|
+
* to the extractor. `stateExtractField`'s plain-format branch is
|
|
4417
|
+
* `^Field:` with the `i` flag, so a frontmatter `status:` key matched the
|
|
4418
|
+
* pattern for the body field `Status` and won, because the frontmatter block
|
|
4419
|
+
* precedes the body. Parsed once now — `extractFrontmatter` +
|
|
4420
|
+
* `stripFrontmatter` — and `fm`/`body` are handed to the chain owner, exactly
|
|
4421
|
+
* as `advancePlanCore`/`beginPhaseCore`/`completePhaseCore`/
|
|
4422
|
+
* `readModifyWriteStateMd` already guard against this class of defect.
|
|
4423
|
+
*
|
|
4424
|
+
* `scope` (ADR-3180 Decision 2) reports whether the derivation actually ran:
|
|
4425
|
+
* - `COMPLETE` — the phase-vs-disk derivation ran over usable input,
|
|
4426
|
+
* including when it legitimately finds no VERIFICATION.md / no matching
|
|
4427
|
+
* phase directory (a real answer, not a non-answer).
|
|
4428
|
+
* - `UNSCOPED` — Current Phase could not be resolved by ANY chain step (no
|
|
4429
|
+
* frontmatter scalar, no body field), so the drift derivation had no
|
|
4430
|
+
* phase to scope its disk lookup to and could not run at all. Reporting
|
|
4431
|
+
* this as COMPLETE would recreate the #3162 collapse this phase closes,
|
|
4432
|
+
* one layer out.
|
|
4433
|
+
* - `UNREADABLE` — the frontmatter parse or the phases-dir scan itself
|
|
4434
|
+
* could not be consulted (an existing `catch` block used to swallow this
|
|
4435
|
+
* silently; the degrade stays, but is now visible).
|
|
4436
|
+
*
|
|
4437
|
+
* ⛔ Rejected (ADR-3180 §7.7 Rejected #2): a non-`COMPLETE` scope is never
|
|
4438
|
+
* routed to `valid:false`. `valid` keeps meaning "no drift warnings were
|
|
4439
|
+
* found"; `scope` says whether the derivation could actually run. A caller
|
|
4440
|
+
* branches on both — folding them into one boolean recreates the exact
|
|
4441
|
+
* collapse this epic removes, in the opposite direction (a legacy STATE.md
|
|
4442
|
+
* with no resolvable phase is a supported degrade, not an invalid document).
|
|
4443
|
+
*/
|
|
4444
|
+
/**
|
|
4445
|
+
* #1255/#3187: parse frontmatter and strip it from the body ONCE, shared by
|
|
4446
|
+
* `cmdStateValidate` and `cmdStateCompletePhase` so both consult the identical
|
|
4447
|
+
* fm/body precedence and degrade identically when the frontmatter half of the
|
|
4448
|
+
* chain cannot be consulted. Extracted (code-review finding, epic #3180): the
|
|
4449
|
+
* two call sites previously carried a byte-identical try/catch, comments
|
|
4450
|
+
* included — an epic whose own thesis is "one canonical owner per
|
|
4451
|
+
* derivation" must not ship a duplicated derivation in its own diff.
|
|
4452
|
+
*
|
|
4453
|
+
* Returns `scope: SCOPE.COMPLETE` unless the frontmatter parse itself threw,
|
|
4454
|
+
* in which case `fm` degrades to `{}` and `scope` becomes `SCOPE.UNREADABLE`
|
|
4455
|
+
* — callers that mutate `scope` further (e.g. `cmdStateValidate`'s later
|
|
4456
|
+
* UNSCOPED/disk-scan degrades) start from this returned value rather than a
|
|
4457
|
+
* fresh `SCOPE.COMPLETE`.
|
|
2580
4458
|
*/
|
|
2581
|
-
function
|
|
4459
|
+
function readStateFrontmatterScoped(content, statePath) {
|
|
4460
|
+
let fm;
|
|
4461
|
+
let scope = SCOPE.COMPLETE;
|
|
4462
|
+
try {
|
|
4463
|
+
fm = extractFrontmatter(content, statePath);
|
|
4464
|
+
}
|
|
4465
|
+
catch {
|
|
4466
|
+
// extractFrontmatter is documented never to throw, but this mirrors the
|
|
4467
|
+
// defensive try/catch already used around it elsewhere in this file
|
|
4468
|
+
// (e.g. spliceFrontmatter) — a parse hiccup here means the frontmatter
|
|
4469
|
+
// half of the chain could not be consulted; degrade visibly.
|
|
4470
|
+
fm = {};
|
|
4471
|
+
scope = SCOPE.UNREADABLE;
|
|
4472
|
+
}
|
|
4473
|
+
const body = stripFrontmatter(content);
|
|
4474
|
+
return { fm, body, scope };
|
|
4475
|
+
}
|
|
4476
|
+
/**
|
|
4477
|
+
* Builds an S0NN `Diagnostic` for `cmdStateValidate` (§8.4 rule 3 —
|
|
4478
|
+
* `cmdStateValidate` is a plain imperative function, not a `Rule.check`, so
|
|
4479
|
+
* it builds `Diagnostic[]` directly rather than going through
|
|
4480
|
+
* `evaluateRuleTable`/the `RULES` array machinery). Every S0NN subject is
|
|
4481
|
+
* advisory-only today (`cmdStateValidate` has never had a repair path), so
|
|
4482
|
+
* every remedy is `adviseRemedy` — `advice` is the short imperative command
|
|
4483
|
+
* text shown to the operator, matching the style Phase 11's rule-group files
|
|
4484
|
+
* already use for their own ADVISE-only findings (e.g.
|
|
4485
|
+
* `roadmap-disk-consistency.cts`'s `adviseRemedy('Create phase directory or
|
|
4486
|
+
* remove from roadmap')`).
|
|
4487
|
+
*/
|
|
4488
|
+
function stateDiagnostic(code, severity, message, advice) {
|
|
4489
|
+
return { code, severity, message, remedy: adviseRemedy(advice) };
|
|
4490
|
+
}
|
|
4491
|
+
function cmdStateValidate(cwd, raw, opts = {}) {
|
|
2582
4492
|
const statePath = planningPaths(cwd).state;
|
|
4493
|
+
// #3696: `valid: false` used to exit 0, so a CI step or git hook could not gate
|
|
4494
|
+
// on state correctness without parsing JSON — every consumer had to
|
|
4495
|
+
// re-implement the "is this actually valid" decision, which is the
|
|
4496
|
+
// duplication #3473 is about.
|
|
4497
|
+
//
|
|
4498
|
+
// The DEFAULT is deliberately unchanged. `state validate`'s exit status is
|
|
4499
|
+
// Tier-2 observable output reaching "downstream projects that cannot be
|
|
4500
|
+
// enumerated" (ADR-3180 Decision 3, Hyrum's Law), so flipping 0 -> 1 for
|
|
4501
|
+
// everyone would break every script that runs it unconditionally. `--strict`
|
|
4502
|
+
// is the opt-in the issue itself offers as the alternative.
|
|
4503
|
+
//
|
|
4504
|
+
// Routed through one emit helper rather than a trailing assignment because
|
|
4505
|
+
// three of the exit paths below (`STATE.md not found`, S001, and the four
|
|
4506
|
+
// `return` branches in the phase-drift scan) emit and return early — a fix
|
|
4507
|
+
// that only set the exit code at the end of the function would silently miss
|
|
4508
|
+
// them, which is exactly the shape of the bug being fixed.
|
|
4509
|
+
const emit = (payload) => {
|
|
4510
|
+
if (opts.strict && payload.valid !== true)
|
|
4511
|
+
process.exitCode = 1;
|
|
4512
|
+
output(payload, raw, undefined);
|
|
4513
|
+
};
|
|
2583
4514
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
2584
|
-
|
|
4515
|
+
emit({ error: 'STATE.md not found' });
|
|
2585
4516
|
return;
|
|
2586
4517
|
}
|
|
2587
4518
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
@@ -2590,67 +4521,174 @@ function cmdStateValidate(cwd, raw) {
|
|
|
2590
4521
|
// searchers downstream, reading as "absent" rather than "corrupt."
|
|
2591
4522
|
const encErr = (0, validate_cjs_1.textEncodingError)(content, 'STATE.md');
|
|
2592
4523
|
if (encErr) {
|
|
2593
|
-
|
|
4524
|
+
// S001 — error-class severity (this branch has always set `valid: false`
|
|
4525
|
+
// unconditionally and returned immediately, matching every other
|
|
4526
|
+
// error-class code, not a mere warning). Message reused verbatim from
|
|
4527
|
+
// `textEncodingError`, not paraphrased.
|
|
4528
|
+
emit({
|
|
4529
|
+
valid: false,
|
|
4530
|
+
warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
|
|
4531
|
+
});
|
|
2594
4532
|
return;
|
|
2595
4533
|
}
|
|
2596
4534
|
const warnings = [];
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
4535
|
+
// #1255/#3187: parse frontmatter and strip it from the body ONCE, so the
|
|
4536
|
+
// chain owner sees the same fm/body precedence every other migrated call
|
|
4537
|
+
// site sees. Pass statePath so a truncated STATE.md is named in the #1882
|
|
4538
|
+
// diagnostic rather than reported under a content digest.
|
|
4539
|
+
const { fm, body, scope: initialScope } = readStateFrontmatterScoped(content, statePath);
|
|
4540
|
+
const scope = initialScope;
|
|
4541
|
+
const status = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'status', 'Status').value || '';
|
|
4542
|
+
const resolvedPhase = resolveStatePhase(fm, body);
|
|
4543
|
+
const currentPhase = resolvedPhase.phase;
|
|
4544
|
+
const totalPlansRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'total_plans_in_phase', 'Total Plans in Phase').value;
|
|
2601
4545
|
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
|
|
2602
4546
|
const phasesDir = planningPaths(cwd).phases;
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
4547
|
+
if (currentPhase === null) {
|
|
4548
|
+
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'));
|
|
4549
|
+
emit({ valid: false, warnings, scope });
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
|
4553
|
+
if (Object.values(resolvedPhase.sources).some(source => source !== null && phaseKeyFromToken(source) !== selectedPhaseKey)) {
|
|
4554
|
+
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'));
|
|
4555
|
+
}
|
|
4556
|
+
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
4557
|
+
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'));
|
|
4558
|
+
emit({ valid: false, warnings, scope });
|
|
4559
|
+
return;
|
|
4560
|
+
}
|
|
4561
|
+
let phaseDirPath;
|
|
4562
|
+
try {
|
|
4563
|
+
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
4564
|
+
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
|
|
4565
|
+
if (!phaseDir) {
|
|
4566
|
+
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'));
|
|
4567
|
+
emit({ valid: false, warnings, scope });
|
|
4568
|
+
return;
|
|
4569
|
+
}
|
|
4570
|
+
phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
|
|
4571
|
+
}
|
|
4572
|
+
catch {
|
|
4573
|
+
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'));
|
|
4574
|
+
emit({ valid: false, warnings, scope });
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
try {
|
|
4578
|
+
const scan = scanPhasePlans(phaseDirPath);
|
|
4579
|
+
if (scan.scope !== SCOPE.COMPLETE) {
|
|
4580
|
+
throw new Error('phase plan scan is incomplete');
|
|
4581
|
+
}
|
|
4582
|
+
const { planCount: diskPlans, summaryCount: diskSummaries } = scan;
|
|
4583
|
+
// Check plan count mismatch
|
|
4584
|
+
if (totalPlansInPhase !== null && diskPlans !== totalPlansInPhase) {
|
|
4585
|
+
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'));
|
|
4586
|
+
}
|
|
4587
|
+
// Check for VERIFICATION.md — scoped to THIS phase's own token (#3511)
|
|
4588
|
+
// so a stray, cross-phase, or ad-hoc VERIFICATION file cannot claim
|
|
4589
|
+
// this phase's status has drifted.
|
|
4590
|
+
//
|
|
4591
|
+
// WARNING-4 (#3511 review): the pre-filter grammar here is
|
|
4592
|
+
// deliberately BROADER than the `-VERIFICATION.md` suffix every
|
|
4593
|
+
// other site in the codebase uses — `.includes('VERIFICATION')`
|
|
4594
|
+
// admits names like `03_VERIFICATION.md` (underscore, no dash) that
|
|
4595
|
+
// the dashed grammar would reject outright. That breadth predates
|
|
4596
|
+
// #3511 and is intentional here (this is a best-effort drift
|
|
4597
|
+
// WARNING scan, not an authoritative single-pick resolver), so it is
|
|
4598
|
+
// left as-is rather than narrowed to match the dashed sites — doing
|
|
4599
|
+
// so would be a separate, un-asked-for behavior change (S006/S007).
|
|
4600
|
+
// What #3511 DOES change is that a name this broader grammar admits
|
|
4601
|
+
// is now ALSO subject to the same `scopeToPhase` membership check as
|
|
4602
|
+
// every dashed-grammar site, so a stray `04_VERIFICATION.md`-shaped
|
|
4603
|
+
// file in phase 03's directory is excluded exactly like a stray
|
|
4604
|
+
// `04-VERIFICATION.md` would be — while `03_VERIFICATION.md` (own
|
|
4605
|
+
// phase, underscore separator) is NOT excluded: `isPhaseArtifact`
|
|
4606
|
+
// (`phase-id.cts`) accepts `_` as a candidate-boundary separator
|
|
4607
|
+
// alongside `-` and `.` for exactly this reason, so an S006/S007
|
|
4608
|
+
// scan of `03-alpha/03_VERIFICATION.md` still resolves to S006
|
|
4609
|
+
// ("verification passed" drift), not a false S007.
|
|
4610
|
+
const files = node_fs_1.default.readdirSync(phaseDirPath);
|
|
4611
|
+
const phaseDirBaseName = node_path_1.default.basename(phaseDirPath);
|
|
4612
|
+
const verificationFiles = scopeToPhase(files.filter(f => f.includes('VERIFICATION') && f.endsWith('.md')), phaseDirBaseName);
|
|
4613
|
+
for (const vf of verificationFiles) {
|
|
4614
|
+
try {
|
|
4615
|
+
const vContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDirPath, vf), 'utf-8');
|
|
4616
|
+
if (/status:\s*passed/i.test(vContent) && /executing/i.test(status)) {
|
|
4617
|
+
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")'));
|
|
2640
4618
|
}
|
|
2641
4619
|
}
|
|
4620
|
+
catch { /* best-effort (#2245 audit): cmdStateValidate is a diagnostic
|
|
4621
|
+
* warnings scan across N VERIFICATION.md files — one unreadable file
|
|
4622
|
+
* (permission/race) must not abort the scan of the rest; it's simply
|
|
4623
|
+
* excluded from drift detection. Does not degrade `scope` — the other
|
|
4624
|
+
* N-1 files were consulted fine. */
|
|
4625
|
+
}
|
|
2642
4626
|
}
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
4627
|
+
// Check if all plans have summaries but status still says executing
|
|
4628
|
+
if (diskPlans > 0 && diskSummaries >= diskPlans && /executing/i.test(status)) {
|
|
4629
|
+
// Only warn if no verification exists (if verification passed, the above warning covers it)
|
|
4630
|
+
if (verificationFiles.length === 0) {
|
|
4631
|
+
// S007 stays WARNING (not INFO): closely related to S006 (both
|
|
4632
|
+
// signal "phase may be ready to advance"), and S006 is WARNING —
|
|
4633
|
+
// giving the sibling condition a different severity for the same
|
|
4634
|
+
// underlying signal would be a false distinction.
|
|
4635
|
+
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"'));
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
catch {
|
|
4640
|
+
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'));
|
|
4641
|
+
}
|
|
4642
|
+
// #3696 — the `last_activity` invariant. Three readers consumed this field
|
|
4643
|
+
// and none of them checked it, so a value no reader can parse validated as
|
|
4644
|
+
// `{valid:true, warnings:[], scope:'complete'}`: the scan ran to completion
|
|
4645
|
+
// and simply never looked. Read through the same owner every other field here
|
|
4646
|
+
// uses (ADR-3180 §7.7) — never a private `stateExtractField` call, which is
|
|
4647
|
+
// what `scripts/lint-state-field-drift.cjs` counts.
|
|
4648
|
+
const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', 'Last activity').value;
|
|
4649
|
+
// NOT FILLED IN IS NOT DRIFT, and that covers three shapes, not one: absent,
|
|
4650
|
+
// blank, and the shipped template's `[YYYY-MM-DD] — [What happened]`
|
|
4651
|
+
// placeholder. Only a value a writer actually supplied can be wrong.
|
|
4652
|
+
if (!(0, state_document_cjs_1.isUnfilledFieldValue)(lastActivity)) {
|
|
4653
|
+
// Calendar validity, not merely `\d{4}-\d{2}-\d{2}` shape: smart-entry's
|
|
4654
|
+
// reader rejects 2026-02-30 via isRealCalendarDate (ADR-227 — validate shape
|
|
4655
|
+
// AND value). Accepting it here would leave the two surfaces disagreeing
|
|
4656
|
+
// about whether the file is usable, which is the complaint #3696 opens with.
|
|
4657
|
+
//
|
|
4658
|
+
// Review round 2: this asserts the LEADING date token, not
|
|
4659
|
+
// `parseProseLastActivityField`'s fully-anchored `date — description`
|
|
4660
|
+
// grammar. That grammar is stricter than any real reader, and routing the
|
|
4661
|
+
// check through it made S008 fire on values smart-entry parses fine (e.g.
|
|
4662
|
+
// `2026-08-24 Shipped feature X`, no dash separator) — the same
|
|
4663
|
+
// two-surfaces-disagree defect, pointing the other way. See
|
|
4664
|
+
// `leadingCalendarDate`.
|
|
4665
|
+
if ((0, state_document_cjs_1.leadingCalendarDate)(lastActivity) === null) {
|
|
4666
|
+
warnings.push(stateDiagnostic('S008', SEVERITY.WARNING, `Unreadable last activity: "${lastActivity}" does not begin with a real calendar date, so no reader can date this project's activity`, 'Rewrite the Last activity line to begin with a date that exists, as "YYYY-MM-DD — what happened"'));
|
|
4667
|
+
}
|
|
4668
|
+
// The attached half of #3696: `templates/state.md` prescribes a single-line
|
|
4669
|
+
// field, but writers emit descriptions long enough to wrap, and
|
|
4670
|
+
// `stateExtractField`'s newline-excluding `(.+)` drops the remainder with no
|
|
4671
|
+
// diagnostic. The DOCUMENT is what violates the template here, so this
|
|
4672
|
+
// reports the violation rather than teaching the reader a multi-line grammar
|
|
4673
|
+
// the template does not sanction (ADR-3180 §7.7 Rejected #1 forbids widening
|
|
4674
|
+
// stateExtractField, which has 20 callers and a CRITICAL blast radius).
|
|
4675
|
+
//
|
|
4676
|
+
// Scan the body ONLY when the body is what was actually read. The ladder
|
|
4677
|
+
// prefers the frontmatter scalar, so a document carrying a clean
|
|
4678
|
+
// `last_activity:` in frontmatter AND a stale, wrapped `Last activity:` line
|
|
4679
|
+
// in the body would otherwise report S009 — and exit 1 under `--strict` —
|
|
4680
|
+
// over a remainder that no reader consumes and whose field is entirely
|
|
4681
|
+
// valid. Asking the owner with an EMPTY body isolates the frontmatter rung
|
|
4682
|
+
// without re-deriving the ladder here (which is what
|
|
4683
|
+
// `scripts/lint-state-field-drift.cjs` counts).
|
|
4684
|
+
const fromFrontmatter = (0, state_document_cjs_1.stateFieldValue)(fm, '', 'last_activity', 'Last activity').value;
|
|
4685
|
+
const dropped = fromFrontmatter !== null ? null : (0, state_document_cjs_1.stateFieldContinuation)(body, 'Last activity');
|
|
4686
|
+
if (dropped !== null) {
|
|
4687
|
+
warnings.push(stateDiagnostic('S009', SEVERITY.WARNING, `Truncated last activity description: "${dropped}" follows the Last activity line and is silently dropped by every reader`, 'Fold the Last activity description onto one line — the template prescribes a single-line field'));
|
|
2650
4688
|
}
|
|
2651
4689
|
}
|
|
2652
4690
|
const valid = warnings.length === 0;
|
|
2653
|
-
|
|
4691
|
+
emit({ valid, warnings, scope });
|
|
2654
4692
|
}
|
|
2655
4693
|
/**
|
|
2656
4694
|
* Gate 2: Sync STATE.md from filesystem ground truth.
|
|
@@ -2665,6 +4703,19 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2665
4703
|
}
|
|
2666
4704
|
const verify = options && options.verify;
|
|
2667
4705
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
4706
|
+
// ADR-3473 §8.5 (#3881): `state sync` is on ADR-3408 §8.3's closed
|
|
4707
|
+
// sanctioned-regenerate list — "the body wins" — and `syncStateFrontmatter`
|
|
4708
|
+
// (below, via `writeStateMd`'s `sanctionedPermanentEmptyFallback`) is
|
|
4709
|
+
// therefore CORRECT to overwrite even an unparseable existing frontmatter
|
|
4710
|
+
// block (git merge-conflict markers, malformed YAML). What was missing was
|
|
4711
|
+
// disclosure: a derived conclusion (`synced: true`) must not be reported as
|
|
4712
|
+
// authoritative when the derivation dropped input it could not resolve
|
|
4713
|
+
// (§8.5) — silently destroying the only copy of an unreadable block with no
|
|
4714
|
+
// signal is "failure is a value" (§8.4) violated. Computed once, up front,
|
|
4715
|
+
// from the pre-write snapshot so both the `--verify` (dry-run) and the real
|
|
4716
|
+
// write branch can surface it identically.
|
|
4717
|
+
const existingSyncFm = extractFrontmatter(content, statePath);
|
|
4718
|
+
const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
|
|
2668
4719
|
const changes = [];
|
|
2669
4720
|
let modified = content;
|
|
2670
4721
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -2710,10 +4761,17 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2710
4761
|
let _highestIncompletePhaseSummaryCount = 0;
|
|
2711
4762
|
for (const dir of entries) {
|
|
2712
4763
|
const dirPath = node_path_1.default.join(phasesDir, dir);
|
|
2713
|
-
const { planCount: plans, summaryCount: summaries
|
|
4764
|
+
const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
|
|
2714
4765
|
totalDiskPlans += plans;
|
|
2715
4766
|
totalDiskSummaries += summaries;
|
|
2716
|
-
|
|
4767
|
+
// ADR-3180 §7.4 (#3186, #2957 disk-strict): route through the single
|
|
4768
|
+
// canonical owner (isPhaseComplete), not scanPhasePlans's own `completed`
|
|
4769
|
+
// field ("are all plans summarized?" — a different question). This is the
|
|
4770
|
+
// same fix buildStateFrontmatter got above; cmdStateSync (`state sync`)
|
|
4771
|
+
// was a second, independent consumer of the same raw field the initial
|
|
4772
|
+
// migration missed — without it, `state sync` and `state json` disagreed
|
|
4773
|
+
// on completed_phases for the identical disk state.
|
|
4774
|
+
if (isPhaseComplete(dirPath).value.complete)
|
|
2717
4775
|
diskCompletedPhases++;
|
|
2718
4776
|
// Track the highest phase with incomplete plans (or any plans)
|
|
2719
4777
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
@@ -2774,27 +4832,64 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
2774
4832
|
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
|
|
2775
4833
|
let milestoneBounded = true;
|
|
2776
4834
|
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
2777
|
-
|
|
2778
|
-
|
|
4835
|
+
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
|
|
4836
|
+
// a hand-rolled, unbounded-substring re-derivation — see the identical
|
|
4837
|
+
// fix in buildStateFrontmatter above.
|
|
4838
|
+
milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr);
|
|
2779
4839
|
}
|
|
2780
4840
|
let percent = null;
|
|
2781
4841
|
if (!milestoneBounded) {
|
|
2782
4842
|
changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
|
|
2783
4843
|
}
|
|
2784
4844
|
else {
|
|
2785
|
-
|
|
2786
|
-
|
|
4845
|
+
// #3217 (ADR-3180 §7.6 rule 4) BLOCKER fix: the prior comment here claimed
|
|
4846
|
+
// `entries` (the raw fs.readdirSync listing above) was "never routed
|
|
4847
|
+
// through listMilestonePhaseDirs, so there is no real Scope to pass" —
|
|
4848
|
+
// that was factually wrong. The same `syncRoadmapRaw`/`syncRoadmapScope`
|
|
4849
|
+
// already parsed above (~3104) is precisely what
|
|
4850
|
+
// `listMilestonePhaseDirs` (via `getMilestonePhaseFilter`) re-derives
|
|
4851
|
+
// from `cwd` to produce a real `Scope` — the identical shape already
|
|
4852
|
+
// threaded through `buildStateFrontmatter`'s `diskScope` above. Calling
|
|
4853
|
+
// it here (discarding `.value`, which duplicates `entries`'s own
|
|
4854
|
+
// retired-phase-filtered listing) gets the real scope without changing
|
|
4855
|
+
// the disk-scan totals computed above.
|
|
4856
|
+
const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
|
|
4857
|
+
if (syncScope !== SCOPE.COMPLETE) {
|
|
4858
|
+
changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
|
|
4859
|
+
}
|
|
4860
|
+
else {
|
|
4861
|
+
const p = (0, state_document_cjs_1.computeProgressPercent)(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases, syncScope);
|
|
4862
|
+
percent = p !== null ? p : 0;
|
|
4863
|
+
}
|
|
2787
4864
|
}
|
|
2788
4865
|
const syncResult = transitionCore(modified, { kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent }, { clock: clock_cjs_1.realClock });
|
|
2789
4866
|
modified = syncResult.content;
|
|
2790
4867
|
const coreChanges = syncResult.data?.changes ?? [];
|
|
2791
4868
|
changes.push(...coreChanges);
|
|
4869
|
+
// #3881 (ADR-3473 §8.5): only warn when a write will actually regenerate the
|
|
4870
|
+
// frontmatter — if nothing changed this run, the unparseable block (if any)
|
|
4871
|
+
// was never touched, so there is nothing to disclose. Mirrors the exact
|
|
4872
|
+
// condition the write branch below uses to decide whether to write at all.
|
|
4873
|
+
const syncWillWrite = changes.length > 0 || modified !== content;
|
|
4874
|
+
if (syncWillWrite && syncFrontmatterWasUnparseable) {
|
|
4875
|
+
const unparseableWarning = `gsd: warning — STATE.md's existing frontmatter could not be parsed (malformed YAML, or ` +
|
|
4876
|
+
`unresolved content such as git merge-conflict markers) and was regenerated from the body; ` +
|
|
4877
|
+
`any content in the old frontmatter block — including merge-conflict markers — has been ` +
|
|
4878
|
+
`replaced. (#3881)`;
|
|
4879
|
+
process.stderr.write(`${unparseableWarning}\n`);
|
|
4880
|
+
changes.push(unparseableWarning);
|
|
4881
|
+
}
|
|
2792
4882
|
if (verify) {
|
|
2793
4883
|
output({ synced: false, changes, dry_run: true }, raw, undefined);
|
|
2794
4884
|
return;
|
|
2795
4885
|
}
|
|
2796
|
-
if (
|
|
2797
|
-
|
|
4886
|
+
if (syncWillWrite) {
|
|
4887
|
+
// ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract —
|
|
4888
|
+
// `state sync` exists to let the body win, so preservation must NOT run,
|
|
4889
|
+
// and the snapshot is carried anyway because §8.7's reporting needs it.
|
|
4890
|
+
writeStateMd(statePath, modified, stateTransitionMod.rebuildStateTransaction({
|
|
4891
|
+
snapshot: extractFrontmatter(content, statePath),
|
|
4892
|
+
}), cwd);
|
|
2798
4893
|
}
|
|
2799
4894
|
output({ synced: true, changes, dry_run: false }, raw, undefined);
|
|
2800
4895
|
}
|
|
@@ -2817,30 +4912,18 @@ function cmdStatePrune(cwd, options, raw) {
|
|
|
2817
4912
|
}
|
|
2818
4913
|
const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
|
|
2819
4914
|
const dryRun = !!options.dryRun;
|
|
2820
|
-
// Resolve the current phase via the
|
|
2821
|
-
//
|
|
2822
|
-
//
|
|
2823
|
-
// "Only 0
|
|
2824
|
-
// #
|
|
2825
|
-
//
|
|
2826
|
-
// fallback matches any `| Phase | N |` row (e.g. a historical verification
|
|
2827
|
-
// table), resolving a stale phase and computing a wrong cutoff. Frontmatter and
|
|
2828
|
-
// the explicit `Current Phase` field are unambiguous, so they stay document-wide;
|
|
2829
|
-
// the shared extractor is not narrowed for any other caller.
|
|
4915
|
+
// Resolve the current phase via `resolveCurrentPhaseId` — the shared owner of
|
|
4916
|
+
// the canonical frontmatter → `Current Phase` field → scoped prose ladder
|
|
4917
|
+
// (#1760 origin, #1776 scoping, #3187 ownership; see its doc comment). Prune
|
|
4918
|
+
// engages on a template-conformant STATE.md instead of bailing "Only 0
|
|
4919
|
+
// phases" (#1760). #3231/#3481 routed the phase-labeled write commands
|
|
4920
|
+
// through the same helper rather than leaving a second copy of the ladder here.
|
|
2830
4921
|
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
2831
4922
|
const fm = extractFrontmatter(rawState, statePath);
|
|
2832
4923
|
const body = stripFrontmatter(rawState);
|
|
2833
|
-
//
|
|
2834
|
-
//
|
|
2835
|
-
|
|
2836
|
-
const fmRawPhase = fm.current_phase;
|
|
2837
|
-
const fmCurrentPhase = typeof fmRawPhase === 'string' ? (fmRawPhase.trim() || null)
|
|
2838
|
-
: typeof fmRawPhase === 'number' || typeof fmRawPhase === 'boolean' ? String(fmRawPhase)
|
|
2839
|
-
: null;
|
|
2840
|
-
const positionSection = sliceCurrentPositionSection(body);
|
|
2841
|
-
const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionSection, 'Phase')).phase : null;
|
|
2842
|
-
const currentPhaseRaw = fmCurrentPhase ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase;
|
|
2843
|
-
const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
|
|
4924
|
+
// Prune needs an integer cutoff, so it parses the resolved id itself; a
|
|
4925
|
+
// non-numeric or absent id lands on 0 and prune bails, as before.
|
|
4926
|
+
const currentPhase = parseInt(String(resolveCurrentPhaseId(fm, body)), 10) || 0;
|
|
2844
4927
|
const cutoff = currentPhase - keepRecent;
|
|
2845
4928
|
if (cutoff <= 0) {
|
|
2846
4929
|
emit({ pruned: false, reason: `Only ${currentPhase} phases — nothing to prune with --keep-recent ${keepRecent}` }, raw, 'false');
|
|
@@ -2946,6 +5029,11 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2946
5029
|
const phasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'phases');
|
|
2947
5030
|
if (!node_fs_1.default.existsSync(phasesDir) || !node_fs_1.default.statSync(phasesDir).isDirectory())
|
|
2948
5031
|
return { ok: true, phases: [] };
|
|
5032
|
+
// #3185: deliberately NOT listMilestonePhaseDirs. `state rebuild` is a
|
|
5033
|
+
// RECONCILIATION pass against ground truth -- it must see every phase
|
|
5034
|
+
// directory on disk so an orphan STATE.md row for a phase that no longer
|
|
5035
|
+
// exists (or sits outside the current window) is dropped. Scoping this
|
|
5036
|
+
// would make the rebuild silently preserve stale rows.
|
|
2949
5037
|
const entries = node_fs_1.default.readdirSync(phasesDir);
|
|
2950
5038
|
const records = [];
|
|
2951
5039
|
for (const entry of entries) {
|
|
@@ -2963,9 +5051,21 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
2963
5051
|
const m = entry.match(/^(\d+)-(.+)$/);
|
|
2964
5052
|
if (!m)
|
|
2965
5053
|
continue;
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
5054
|
+
// #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source
|
|
5055
|
+
// planCount/summaryCount from the single owner (scanPhasePlans)
|
|
5056
|
+
// instead of a local root-only `-PLAN.md`/`-SUMMARY.md` readdirSync
|
|
5057
|
+
// filter — picks up bare PLAN.md/SUMMARY.md and nested plans/. A
|
|
5058
|
+
// non-COMPLETE scope (TRUNCATED: nested plans/ unreadable;
|
|
5059
|
+
// UNREADABLE: `full` itself unreadable) is not a trustworthy count —
|
|
5060
|
+
// throw so it surfaces via the outer catch as a real scan failure
|
|
5061
|
+
// (`ok:false`), mirroring the #3057 B1 contract documented above for
|
|
5062
|
+
// the sibling `fs.readdirSync(phasesDir)` failure mode, rather than
|
|
5063
|
+
// silently reporting an undercount.
|
|
5064
|
+
const scan = scanPhasePlans(full);
|
|
5065
|
+
if (scan.scope !== SCOPE.COMPLETE) {
|
|
5066
|
+
throw new Error(`could not fully scan plan directory (scope ${scan.scope}): ${full}`);
|
|
5067
|
+
}
|
|
5068
|
+
const { planCount, summaryCount } = scan;
|
|
2969
5069
|
records.push({ number: m[1], name: m[2], planCount, summaryCount });
|
|
2970
5070
|
}
|
|
2971
5071
|
return { ok: true, phases: records };
|
|
@@ -3048,10 +5148,17 @@ function cmdStateRebuild(cwd, options, raw) {
|
|
|
3048
5148
|
* that the phase execution is finished and the project is ready for the next phase.
|
|
3049
5149
|
* Implements the `gsd state complete-phase` subcommand (issue #2735).
|
|
3050
5150
|
*/
|
|
3051
|
-
function resolvePhaseIdForCompletePhase(
|
|
5151
|
+
function resolvePhaseIdForCompletePhase(fm, body, overridePhase) {
|
|
5152
|
+
// #3187: route through the single #1760 fallback-chain owner (fm scalar
|
|
5153
|
+
// then body field) instead of two raw stateExtractField calls on
|
|
5154
|
+
// frontmatter-blind content — a STATE.md whose phase lives only in
|
|
5155
|
+
// frontmatter no longer resolves to null here. `Phase` (the historical
|
|
5156
|
+
// second-choice field name) has no frontmatter counterpart, so its fmKey
|
|
5157
|
+
// is null — same shape as cmdStateSnapshot's `stateFieldValue(fm,
|
|
5158
|
+
// currentPositionScope, null, 'Phase')` fallback.
|
|
3052
5159
|
const candidate = overridePhase ||
|
|
3053
|
-
(0, state_document_cjs_1.
|
|
3054
|
-
(0, state_document_cjs_1.
|
|
5160
|
+
(0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value ||
|
|
5161
|
+
(0, state_document_cjs_1.stateFieldValue)(fm, body, null, 'Phase').value ||
|
|
3055
5162
|
'';
|
|
3056
5163
|
// #2125: parse via the canonical anchored parser so a narrative `Phase:`
|
|
3057
5164
|
// body line (e.g. "Milestone v0.5 complete") does not mine a bogus token —
|
|
@@ -3068,7 +5175,30 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3068
5175
|
return;
|
|
3069
5176
|
}
|
|
3070
5177
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
3071
|
-
|
|
5178
|
+
// #1255/#3187: parse frontmatter and strip it from the body ONCE, mirroring
|
|
5179
|
+
// cmdStateValidate/cmdStateSnapshot, so resolvePhaseIdForCompletePhase and
|
|
5180
|
+
// the idempotency guard below consult the identical fm/body precedence —
|
|
5181
|
+
// the two sites cannot drift onto different chains, extending the #2125
|
|
5182
|
+
// "same canonical parser" guarantee one layer earlier.
|
|
5183
|
+
const { fm, body, scope } = readStateFrontmatterScoped(content, statePath);
|
|
5184
|
+
// #3187 Postel/visibility (design doc's sharpest case): this whole handler
|
|
5185
|
+
// is the DESTRUCTIVE path the #3489 idempotency guard below protects — it
|
|
5186
|
+
// decides whether a re-run of `state complete-phase --phase N` is allowed
|
|
5187
|
+
// to roll STATE.md back to N's moment-of-completion. If the frontmatter
|
|
5188
|
+
// half of the chain could not be consulted (`scope` UNREADABLE),
|
|
5189
|
+
// `existingCurrentPhase` below could read as null even though the
|
|
5190
|
+
// project's true current phase lives only in that unreadable frontmatter —
|
|
5191
|
+
// silently treating a non-COMPLETE scope as "not complete" would let the
|
|
5192
|
+
// guard's `existingCurrentPhase &&` check fail OPEN and re-run an
|
|
5193
|
+
// already-completed phase. Refuse outright instead of guessing; this
|
|
5194
|
+
// applies even when `--phase` is explicit, because the guard's job is to
|
|
5195
|
+
// protect against exactly that already-completed-phase case regardless of
|
|
5196
|
+
// how the target phase was named.
|
|
5197
|
+
if (scope !== SCOPE.COMPLETE) {
|
|
5198
|
+
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);
|
|
5199
|
+
return;
|
|
5200
|
+
}
|
|
5201
|
+
const resolvedPhase = resolvePhaseIdForCompletePhase(fm, body, overridePhase);
|
|
3072
5202
|
if (!resolvedPhase || /^phase$/i.test(resolvedPhase)) {
|
|
3073
5203
|
output({ error: 'Unable to resolve current phase. Pass an explicit phase: state complete-phase --phase <N>' }, raw, undefined);
|
|
3074
5204
|
return;
|
|
@@ -3082,7 +5212,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3082
5212
|
// Last Activity, Last Activity Description, and the Current Position body.
|
|
3083
5213
|
// The handler is now a no-op in that case so re-invocation from downstream
|
|
3084
5214
|
// workflows cannot regress the project state.
|
|
3085
|
-
const existingCurrentPhaseRaw = (0, state_document_cjs_1.
|
|
5215
|
+
const existingCurrentPhaseRaw = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'current_phase', 'Current Phase').value || '';
|
|
3086
5216
|
// #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
|
|
3087
5217
|
// sites cannot diverge on the token they extract.
|
|
3088
5218
|
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
|
|
@@ -3091,34 +5221,67 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3091
5221
|
return;
|
|
3092
5222
|
}
|
|
3093
5223
|
const today = clock_cjs_1.realClock.localToday();
|
|
5224
|
+
// #3408 review (close-known-limits): `updated` mixes two different kinds of
|
|
5225
|
+
// thing — FIELD names (Status, Last Activity, ...), each reconcilable
|
|
5226
|
+
// against the persisted bytes via `reconcileReportedFields`, and the
|
|
5227
|
+
// SECTION name `Current Position` (the whole Current-Position block, not a
|
|
5228
|
+
// single field `stateExtractField` can look up). Rather than re-deriving
|
|
5229
|
+
// the distinction downstream by string-matching against a Set, each entry
|
|
5230
|
+
// now carries its kind at the point it is PRODUCED; the flattening to a
|
|
5231
|
+
// flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
|
|
5232
|
+
// below, right before `output()`.
|
|
3094
5233
|
const updated = [];
|
|
3095
|
-
|
|
5234
|
+
const divergedFields = [];
|
|
5235
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
5236
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
5237
|
+
const preWriteState = {};
|
|
5238
|
+
// #3835: complete-phase unconditionally rewrites the body `Phase:` line to
|
|
5239
|
+
// `N — COMPLETE` below. That defeats current_phase_name's
|
|
5240
|
+
// preserve-when-unchanged delta rule the same way #3834's no-`--name`
|
|
5241
|
+
// planned-phase write does — pre/post body-source disagree BY CONSTRUCTION
|
|
5242
|
+
// (this write is what changed the source line), so the post-sync
|
|
5243
|
+
// re-derivation harvests nothing from "COMPLETE" and the curated key is
|
|
5244
|
+
// dropped entirely rather than preserved. The write site already documents
|
|
5245
|
+
// "an absent name does NOT clear an existing curated value" for the body
|
|
5246
|
+
// (`Current Phase Name` section below) — this reasserts the same rule for
|
|
5247
|
+
// the frontmatter key, mirroring cmdStatePlannedPhase's fix.
|
|
5248
|
+
const rmwOptions = { divergedFields, preWriteState };
|
|
5249
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
3096
5250
|
const currentPhase = resolvedPhase;
|
|
3097
5251
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
3098
5252
|
// cannot shadow the body Status field (pipe-table or inline).
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
5253
|
+
//
|
|
5254
|
+
// ADR-3473 §8.1 (#3881 review, finding 5): previously this block hand-reimplemented
|
|
5255
|
+
// the isUnparseableFrontmatter/rawFrontmatterPrefix shape inline instead of using the
|
|
5256
|
+
// canonical helper — the sixth copy of a block already duplicated 5x in
|
|
5257
|
+
// state-transition.cts. Routed through the shared `beginFrontmatterReassembly` so this
|
|
5258
|
+
// module can never drift from the frontmatter-preservation contract state-transition.cts
|
|
5259
|
+
// enforces everywhere else.
|
|
5260
|
+
const { existingFm, body: initialBody, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
|
|
5261
|
+
let body = initialBody;
|
|
5262
|
+
const curatedPhaseName = existingFm['current_phase_name'];
|
|
5263
|
+
if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) {
|
|
5264
|
+
rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName };
|
|
5265
|
+
}
|
|
3103
5266
|
// Update Status field (body only — #1255)
|
|
3104
5267
|
const statusValue = `Phase ${currentPhase} complete`;
|
|
3105
5268
|
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
3106
5269
|
if (result) {
|
|
3107
5270
|
body = result;
|
|
3108
|
-
updated.push('Status');
|
|
5271
|
+
updated.push({ kind: 'field', name: 'Status' });
|
|
3109
5272
|
}
|
|
3110
5273
|
// Update Last Activity date
|
|
3111
5274
|
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today);
|
|
3112
5275
|
if (result) {
|
|
3113
5276
|
body = result;
|
|
3114
|
-
updated.push('Last Activity');
|
|
5277
|
+
updated.push({ kind: 'field', name: 'Last Activity' });
|
|
3115
5278
|
}
|
|
3116
5279
|
// Update Last Activity Description
|
|
3117
5280
|
const activityDesc = `Phase ${currentPhase} marked complete`;
|
|
3118
5281
|
result = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', activityDesc);
|
|
3119
5282
|
if (result) {
|
|
3120
5283
|
body = result;
|
|
3121
|
-
updated.push('Last Activity Description');
|
|
5284
|
+
updated.push({ kind: 'field', name: 'Last Activity Description' });
|
|
3122
5285
|
}
|
|
3123
5286
|
// Update ## Current Position section
|
|
3124
5287
|
// ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
|
|
@@ -3177,12 +5340,37 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
3177
5340
|
posBody = replaced;
|
|
3178
5341
|
}
|
|
3179
5342
|
body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd);
|
|
3180
|
-
updated.push('Current Position');
|
|
5343
|
+
updated.push({ kind: 'section', name: 'Current Position' });
|
|
3181
5344
|
}
|
|
3182
5345
|
}
|
|
3183
5346
|
return reassemble(body);
|
|
3184
|
-
}, cwd);
|
|
3185
|
-
|
|
5347
|
+
}, cwd, rmwOptions);
|
|
5348
|
+
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
|
|
5349
|
+
// the analysis pass"). Unlike the transitionCore-based commands, this
|
|
5350
|
+
// adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
|
|
5351
|
+
// Activity Description — each reconcilable against the persisted bytes,
|
|
5352
|
+
// same as every other command in this phase) with the SECTION entry
|
|
5353
|
+
// `Current Position` (the whole Current-Position block, not a single
|
|
5354
|
+
// field `stateExtractField` can look up — reconciling it the same way as
|
|
5355
|
+
// a field would always drop it as a false negative). Reconcile only the
|
|
5356
|
+
// field-shaped entries (#3351's direction), pass the section entry
|
|
5357
|
+
// through unconditionally, and fold in any field preservation restored
|
|
5358
|
+
// that this transform never touched (#3345's direction). The kind was
|
|
5359
|
+
// decided at PUSH time above (typed producer), not re-derived here by
|
|
5360
|
+
// string-matching a name against a Set.
|
|
5361
|
+
const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
|
|
5362
|
+
const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
|
|
5363
|
+
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preWriteState, fieldEntries, divergedFields)];
|
|
5364
|
+
output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
|
|
5365
|
+
// #3227: gate on `wrote` (readModifyWriteStateMd's own return value), not
|
|
5366
|
+
// `reconciled.length > 0` — a re-run of complete-phase against a phase
|
|
5367
|
+
// that is ALREADY marked complete (same status/date/Current Position
|
|
5368
|
+
// values already on disk) still has `stateReplaceField` report a match for
|
|
5369
|
+
// every field it looks up, so `reconciled` is non-empty even though the
|
|
5370
|
+
// #948 no-op guard skipped the write. Same reasoning as
|
|
5371
|
+
// cmdStateBeginPhase/cmdStatePlannedPhase/cmdStateAdvancePlan above.
|
|
5372
|
+
if (wrote)
|
|
5373
|
+
publishStateContract(cwd);
|
|
3186
5374
|
}
|
|
3187
5375
|
module.exports = {
|
|
3188
5376
|
stateExtractField: state_document_cjs_1.stateExtractField,
|
|
@@ -3193,6 +5381,17 @@ module.exports = {
|
|
|
3193
5381
|
writeStateMd,
|
|
3194
5382
|
readModifyWriteStateMd,
|
|
3195
5383
|
syncStateFrontmatter,
|
|
5384
|
+
// #3374: the shared post-sync preservation pass (snapshots + table-driven
|
|
5385
|
+
// applyStatePreservation + #2736 re-assert).
|
|
5386
|
+
applyPostSyncPreservation,
|
|
5387
|
+
// #3469 (ADR-3408 §8.3): the ONE write-seam composition (sync +
|
|
5388
|
+
// preservation) as content -> content. Exported for cmdPhaseComplete's
|
|
5389
|
+
// atomic-commit adapter (phase.cts, syncs STATE.md directly because it is
|
|
5390
|
+
// committed atomically with ROADMAP/REQUIREMENTS) and for
|
|
5391
|
+
// cmdMilestoneComplete (milestone.cts) — both need the composition's
|
|
5392
|
+
// output but supply their own I/O envelope around it.
|
|
5393
|
+
syncAndPreserveStateMd,
|
|
5394
|
+
readStateHeadFreshness,
|
|
3196
5395
|
withStateLock,
|
|
3197
5396
|
updatePerformanceMetricsSection,
|
|
3198
5397
|
cmdStateLoad,
|
|
@@ -3222,6 +5421,27 @@ module.exports = {
|
|
|
3222
5421
|
// Test seam (#1514): the pure retired/folded-phase parser, exposed so its
|
|
3223
5422
|
// strikethrough-detection logic can be property-tested directly.
|
|
3224
5423
|
_extractRetiredPhaseNumbers: extractRetiredPhaseNumbers,
|
|
5424
|
+
// Test seam (#3471 review): the second hand-maintained table beside
|
|
5425
|
+
// FIELD_CLASSIFICATION, exposed so a parity test can pin that every
|
|
5426
|
+
// `preserve-when-unchanged` row has a label here.
|
|
5427
|
+
_FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
|
|
5428
|
+
// Test seam (ADR-3473 §8.7, #3872): the transaction diff and its pure
|
|
5429
|
+
// building blocks, exposed so the ~15 boundary/hostile/property rows in
|
|
5430
|
+
// the test matrix (dotted-path resolution, prototype-pollution safety,
|
|
5431
|
+
// string/number representation insensitivity, the provenance exclusion)
|
|
5432
|
+
// can be driven directly with fabricated snapshot/persisted objects
|
|
5433
|
+
// instead of round-tripping every case through a full RMW write.
|
|
5434
|
+
_reconcileReportedFields: reconcileReportedFields,
|
|
5435
|
+
_computeChangedFrontmatterFields: computeChangedFrontmatterFields,
|
|
5436
|
+
_resolveFrontmatterPath: resolveFrontmatterPath,
|
|
5437
|
+
_stateFieldValuesDiffer: stateFieldValuesDiffer,
|
|
5438
|
+
_STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION,
|
|
5439
|
+
// Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not
|
|
5440
|
+
// otherwise reachable from outside this module. Exposed so a test can drive
|
|
5441
|
+
// the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only
|
|
5442
|
+
// pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
|
|
5443
|
+
// itself.
|
|
5444
|
+
_bodyLabelFor: bodyLabelFor,
|
|
3225
5445
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
3226
5446
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
3227
5447
|
_setLockProbes(probes) {
|