@opengsd/gsd-core 1.11.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 +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +17 -9
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +0 -1
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +3 -1
- package/agents/gsd-plan-checker.md +15 -55
- package/agents/gsd-planner.md +6 -4
- 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-verifier.md +1 -1
- package/bin/install.js +609 -134
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/quick.md +8 -4
- package/gsd-core/bin/gsd-tools.cjs +567 -51
- package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
- package/gsd-core/bin/lib/api-coverage.cjs +30 -9
- package/gsd-core/bin/lib/artifacts.cjs +2 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
- package/gsd-core/bin/lib/audit.cjs +163 -41
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +336 -95
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-validator.cjs +205 -18
- package/gsd-core/bin/lib/check-command-router.cjs +145 -5
- 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 +410 -4
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +543 -44
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +118 -29
- package/gsd-core/bin/lib/config.cjs +92 -2
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +84 -7
- 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/frontmatter.cjs +840 -305
- package/gsd-core/bin/lib/gap-checker.cjs +27 -3
- package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
- package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +120 -41
- package/gsd-core/bin/lib/install-engine.cjs +68 -3
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
- package/gsd-core/bin/lib/install-profiles.cjs +78 -4
- 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 +10 -7
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +160 -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-table.cjs +52 -4
- package/gsd-core/bin/lib/milestone.cjs +90 -5
- package/gsd-core/bin/lib/model-catalog.cjs +177 -19
- package/gsd-core/bin/lib/model-resolver.cjs +10 -28
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +70 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
- package/gsd-core/bin/lib/phase-locator.cjs +138 -17
- package/gsd-core/bin/lib/phase.cjs +405 -84
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-scan.cjs +13 -2
- 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-snapshot.cjs +18 -14
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
- package/gsd-core/bin/lib/probe-core.cjs +4 -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 +71 -45
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
- package/gsd-core/bin/lib/roadmap.cjs +74 -19
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
- package/gsd-core/bin/lib/smart-entry.cjs +12 -22
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +47 -18
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +186 -0
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +517 -101
- package/gsd-core/bin/lib/state.cjs +946 -163
- package/gsd-core/bin/lib/surface.cjs +10 -2
- 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/uat-predicate.cjs +58 -20
- package/gsd-core/bin/lib/uat.cjs +1376 -125
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
- package/gsd-core/bin/lib/unusable-input.cjs +13 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/vendor/README.md +43 -5
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/verification.cjs +14 -1
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +95 -40
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- 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 +3 -2
- 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 +37 -19
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +1 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- 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 +3 -3
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +22 -0
- package/gsd-core/references/model-profiles.md +1 -1
- 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-human-verify-mode.md +15 -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 +37 -8
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/runtime-aware-dispatch.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 +2 -2
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +11 -11
- 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/phase-prompt.md +3 -0
- package/gsd-core/templates/state.md +7 -0
- 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 +4 -9
- 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 +10 -26
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +1 -1
- package/gsd-core/workflows/code-review.md +121 -40
- package/gsd-core/workflows/complete-milestone.md +15 -10
- package/gsd-core/workflows/debug.md +5 -3
- package/gsd-core/workflows/diagnose-issues.md +12 -6
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
- 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 +2 -2
- 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 +1 -1
- package/gsd-core/workflows/eval-review.md +4 -9
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
- 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 +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -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 +38 -54
- package/gsd-core/workflows/execute-plan.md +17 -12
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +2 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +3 -6
- package/gsd-core/workflows/import.md +14 -11
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +44 -11
- 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 +2 -2
- package/gsd-core/workflows/new-milestone.md +9 -21
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +12 -26
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -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 +121 -42
- package/gsd-core/workflows/plan-review-convergence.md +46 -9
- 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 +27 -12
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +20 -29
- 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 +193 -15
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +2 -2
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +3 -5
- package/gsd-core/workflows/ship.md +12 -6
- 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 +23 -1
- 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 +1 -1
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +5 -5
- package/gsd-core/workflows/ui-phase.md +10 -16
- package/gsd-core/workflows/ui-review.md +6 -10
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +6 -10
- package/gsd-core/workflows/validate-phase.md +2 -2
- 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 +57 -18
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- 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 +3 -1
- 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 +28 -23
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
- 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 +16 -7
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +17 -8
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +215 -26
- 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 +34 -16
- 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-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- 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 +3 -1
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +28 -23
- package/hooks/gsd-cursor-subagent-stop.js +3 -1
- 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 +16 -7
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +17 -8
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +215 -26
- 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 +34 -16
- 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-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +12 -7
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +5 -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 +45 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-capability-registry.cjs +8 -6
- 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-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-loop-host-contract.cjs +134 -1
- 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/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +32 -2
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- 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 +4 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +21 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-removed-but-needed.cjs +184 -16
- 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-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +122 -4
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +51 -64
- package/scripts/lint-vendored-deps.cjs +208 -35
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/prompt-injection-scan.sh +75 -14
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +22 -3
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -109
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/state-write-path-drift-baseline.json +0 -19
|
@@ -16,6 +16,12 @@ const pattern_cjs_1 = require("./pattern.cjs");
|
|
|
16
16
|
const ioMod = require("./io.cjs");
|
|
17
17
|
const { output, error } = ioMod;
|
|
18
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
|
|
19
25
|
const configLoaderMod = require("./config-loader.cjs");
|
|
20
26
|
const { loadConfig } = configLoaderMod;
|
|
21
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -23,7 +29,8 @@ const phaseIdMod = require("./phase-id.cjs");
|
|
|
23
29
|
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase, } = phaseIdMod;
|
|
24
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
31
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
26
|
-
|
|
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;
|
|
27
34
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
28
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
36
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
@@ -31,7 +38,18 @@ const { planningDir, planningPaths } = planningWorkspace;
|
|
|
31
38
|
const clock_cjs_1 = require("./clock.cjs");
|
|
32
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
40
|
const frontmatter = require("./frontmatter.cjs");
|
|
34
|
-
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = 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
|
+
}
|
|
35
53
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
54
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
37
55
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -45,6 +63,10 @@ const phaseLocatorMod = require("./phase-locator.cjs");
|
|
|
45
63
|
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
46
64
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
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");
|
|
48
70
|
// #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
|
|
49
71
|
// node builtins, so it introduces no cycle on this path.
|
|
50
72
|
const project_root_cjs_1 = require("./project-root.cjs");
|
|
@@ -54,6 +76,12 @@ const project_root_cjs_1 = require("./project-root.cjs");
|
|
|
54
76
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
55
77
|
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
56
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;
|
|
57
85
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
58
86
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
59
87
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
@@ -233,7 +261,7 @@ function cmdStateLoad(cwd, raw) {
|
|
|
233
261
|
`state_exists=${stateExists}`,
|
|
234
262
|
];
|
|
235
263
|
process.stdout.write(lines.join('\n'));
|
|
236
|
-
|
|
264
|
+
throw new ExitError(0);
|
|
237
265
|
}
|
|
238
266
|
output(result, false, undefined);
|
|
239
267
|
}
|
|
@@ -312,14 +340,15 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
312
340
|
// delta (table-driven) that this phase adds. Field-name validation (security)
|
|
313
341
|
// and the resync-progress decision stay in this adapter.
|
|
314
342
|
let precomputed = { updated: [], failed: [] };
|
|
315
|
-
let preSyncContent = '';
|
|
316
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 = {};
|
|
317
347
|
readModifyWriteStateMd(statePath, (content) => {
|
|
318
348
|
const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
|
|
319
349
|
precomputed = result.data ?? precomputed;
|
|
320
|
-
preSyncContent = result.content;
|
|
321
350
|
return result.content;
|
|
322
|
-
}, cwd, { resync: shouldResync, divergedFields });
|
|
351
|
+
}, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync, preWriteState });
|
|
323
352
|
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
|
|
324
353
|
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
|
325
354
|
// MATCHED — but its plain-line pattern (`m` flag over the full document)
|
|
@@ -334,7 +363,7 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
334
363
|
// `applyStatePreservation` restored that this patch never named at all
|
|
335
364
|
// (#3345's direction), a case the pre-#3471 version of this command never
|
|
336
365
|
// covered.
|
|
337
|
-
const updated = reconcileReportedFields(statePath,
|
|
366
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
|
|
338
367
|
const updatedSet = new Set(updated);
|
|
339
368
|
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
|
|
340
369
|
const results = { updated, failed };
|
|
@@ -344,6 +373,53 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
344
373
|
error('STATE.md not found');
|
|
345
374
|
}
|
|
346
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
|
+
}
|
|
347
423
|
function cmdStateUpdate(cwd, field, value) {
|
|
348
424
|
if (!field || value === undefined) {
|
|
349
425
|
error('field and value required for state update');
|
|
@@ -358,7 +434,30 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
358
434
|
const statePath = planningPaths(cwd).state;
|
|
359
435
|
try {
|
|
360
436
|
let updated = false;
|
|
361
|
-
|
|
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 = {};
|
|
362
461
|
const divergedFields = [];
|
|
363
462
|
const shouldResync = shouldResyncStateProgress([field]);
|
|
364
463
|
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
|
@@ -370,9 +469,12 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
370
469
|
readModifyWriteStateMd(statePath, (content) => {
|
|
371
470
|
const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
|
|
372
471
|
updated = result.data?.updated === true;
|
|
373
|
-
|
|
472
|
+
transitionData = result.data;
|
|
473
|
+
if (transitionData?.wroteFrontmatter === true) {
|
|
474
|
+
authoritativeFm[field] = value;
|
|
475
|
+
}
|
|
374
476
|
return result.content;
|
|
375
|
-
}, cwd, { resync: shouldResync, divergedFields });
|
|
477
|
+
}, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync, preWriteState });
|
|
376
478
|
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
|
377
479
|
// `updateCore`'s own match does not know whether sync/preservation later
|
|
378
480
|
// discarded the value it wrote (#3351's direction, generalized from
|
|
@@ -380,14 +482,37 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
380
482
|
// restored during this write that this command never touched at all
|
|
381
483
|
// (#3345's direction) — reported separately from `updated` because this
|
|
382
484
|
// command's contract is a single-field boolean, not a per-field array.
|
|
383
|
-
const reconciled = reconcileReportedFields(statePath,
|
|
485
|
+
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
|
|
384
486
|
updated = reconciled.includes(field);
|
|
385
487
|
const preserved = reconciled.filter((f) => f !== field);
|
|
386
488
|
if (updated) {
|
|
387
|
-
|
|
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
|
+
}
|
|
388
513
|
}
|
|
389
514
|
else {
|
|
390
|
-
output({ updated: false, reason:
|
|
515
|
+
output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
|
|
391
516
|
}
|
|
392
517
|
}
|
|
393
518
|
catch {
|
|
@@ -434,13 +559,15 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
434
559
|
};
|
|
435
560
|
let resultData;
|
|
436
561
|
let precomputedUpdated = [];
|
|
437
|
-
let preSyncContent = '';
|
|
438
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 = {};
|
|
439
566
|
// #3311: the milestone (phase + session) claim is consulted INSIDE the
|
|
440
567
|
// STATE.md lock, so the position read and the claim read cannot interleave
|
|
441
568
|
// with another session's Current Position write.
|
|
442
569
|
let milestoneConflict = null;
|
|
443
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
570
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
444
571
|
// advance-plan has no phase argument of its own — the phase it advances is
|
|
445
572
|
// whatever ## Current Position names. Compare that against the milestone
|
|
446
573
|
// claim: a mismatch means another session moved the single-slot position
|
|
@@ -458,10 +585,19 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
458
585
|
const result = transitionCore(content, intent, deps);
|
|
459
586
|
resultData = result.data;
|
|
460
587
|
precomputedUpdated = result.updated;
|
|
461
|
-
preSyncContent = result.content;
|
|
462
588
|
return result.content;
|
|
463
|
-
}, cwd, { divergedFields });
|
|
589
|
+
}, cwd, { divergedFields, preWriteState });
|
|
464
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
|
+
}
|
|
465
601
|
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
|
|
466
602
|
return;
|
|
467
603
|
}
|
|
@@ -471,13 +607,25 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
471
607
|
// Generalizes fix(#3351) (closes #3351's direction) and folds in any field
|
|
472
608
|
// preservation restored that this transform never touched (#3345's
|
|
473
609
|
// direction).
|
|
474
|
-
const updated = reconcileReportedFields(statePath,
|
|
610
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
475
611
|
if (resultData['advanced'] === false) {
|
|
476
612
|
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
|
|
477
613
|
}
|
|
478
614
|
else {
|
|
479
615
|
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
|
|
480
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);
|
|
481
629
|
}
|
|
482
630
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
483
631
|
const statePath = planningPaths(cwd).state;
|
|
@@ -1138,8 +1286,10 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1138
1286
|
const now = clock_cjs_1.realClock.nowIso();
|
|
1139
1287
|
const updated = [];
|
|
1140
1288
|
let sessionCreated = false;
|
|
1141
|
-
let preSyncContent = '';
|
|
1142
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 = {};
|
|
1143
1293
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1144
1294
|
// Update Last session / Last Date
|
|
1145
1295
|
let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
|
|
@@ -1353,14 +1503,13 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1353
1503
|
updated.push('Resume File');
|
|
1354
1504
|
}
|
|
1355
1505
|
}
|
|
1356
|
-
preSyncContent = content;
|
|
1357
1506
|
return content;
|
|
1358
|
-
}, cwd, { divergedFields });
|
|
1507
|
+
}, cwd, { divergedFields, preWriteState });
|
|
1359
1508
|
// ADR-3408 §8.4 (D4): reconcile this command's own success list against the
|
|
1360
1509
|
// bytes actually persisted (fix(#3351) generalized) and fold in any field
|
|
1361
1510
|
// preservation restored that this transform never touched (#3345's
|
|
1362
1511
|
// direction).
|
|
1363
|
-
const reconciledUpdated = reconcileReportedFields(statePath,
|
|
1512
|
+
const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
|
|
1364
1513
|
if (reconciledUpdated.length > 0) {
|
|
1365
1514
|
const result = { recorded: true, updated: reconciledUpdated };
|
|
1366
1515
|
if (sessionCreated)
|
|
@@ -1961,10 +2110,19 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1961
2110
|
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
|
|
1962
2111
|
// version-token requirement); see hasMilestoneSectioning's own
|
|
1963
2112
|
// doc comment for why that distinction is load-bearing.
|
|
1964
|
-
|
|
1965
|
-
|
|
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);
|
|
1966
2124
|
const safeToUseRoadmapCount = milestoneBounded
|
|
1967
|
-
|| (roadmapPhaseCount > 0 && !
|
|
2125
|
+
|| (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
|
|
1968
2126
|
// #3354: the milestoned-but-unbounded sibling of the #2828/#3204
|
|
1969
2127
|
// shapes. The whole-document roadmapPhaseCount is rightly rejected
|
|
1970
2128
|
// above (it would conflate sibling milestones, #1761), but the
|
|
@@ -1980,9 +2138,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1980
2138
|
// The degenerate un-sectioned zero-heading case keeps the
|
|
1981
2139
|
// phaseDirs.length fallback — with nothing declared anywhere else,
|
|
1982
2140
|
// the disk count is the only source and remains correct.
|
|
1983
|
-
const milestonedButUnbounded = !milestoneBounded &&
|
|
2141
|
+
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
|
|
1984
2142
|
if (milestonedButUnbounded) {
|
|
1985
|
-
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries
|
|
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`);
|
|
1986
2144
|
}
|
|
1987
2145
|
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
|
1988
2146
|
// absent/unreadable the #549 heading counter never ran (roadmapScope
|
|
@@ -2344,6 +2502,28 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2344
2502
|
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
|
|
2345
2503
|
// derivable here without widening the signature (#1882).
|
|
2346
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
|
+
}
|
|
2347
2527
|
const body = stripFrontmatter(content);
|
|
2348
2528
|
// #3017: pass the stored milestone from the existing frontmatter so
|
|
2349
2529
|
// buildStateFrontmatter scopes its disk scan to the correct milestone
|
|
@@ -2531,7 +2711,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2531
2711
|
// #3257: propagate full-line frontmatter comments from the extracted source onto the
|
|
2532
2712
|
// rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
|
|
2533
2713
|
// skip the Symbol-keyed channel, so without this the comments would be lost here even
|
|
2534
|
-
// though
|
|
2714
|
+
// though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation).
|
|
2535
2715
|
propagateCommentChannel(existingFm, derivedFm);
|
|
2536
2716
|
const yamlStr = reconstructFrontmatter(derivedFm);
|
|
2537
2717
|
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
@@ -2783,7 +2963,23 @@ function withStateLock(statePath, fn) {
|
|
|
2783
2963
|
* @param clock
|
|
2784
2964
|
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
|
2785
2965
|
*/
|
|
2786
|
-
|
|
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
|
+
}
|
|
2787
2983
|
const lockPath = acquireStateLock(statePath, clock);
|
|
2788
2984
|
// Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
|
|
2789
2985
|
// concurrent writer landing in the (now-closed) scan→lock window.
|
|
@@ -2805,10 +3001,14 @@ function writeStateMd(statePath, content, cwd, clock) {
|
|
|
2805
3001
|
_diskScanCache.delete(cwd);
|
|
2806
3002
|
// ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
|
|
2807
3003
|
// sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
|
|
2808
|
-
//
|
|
2809
|
-
//
|
|
2810
|
-
// `
|
|
2811
|
-
|
|
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');
|
|
2812
3012
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
2813
3013
|
}
|
|
2814
3014
|
finally {
|
|
@@ -2863,10 +3063,7 @@ function assertStatePreservationOptions(options, caller) {
|
|
|
2863
3063
|
}
|
|
2864
3064
|
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
|
|
2865
3065
|
assertStatePreservationOptions(options, 'applyPostSyncPreservation');
|
|
2866
|
-
const { resync, authoritativeFm, deriveProgressKeys, divergedFields } = options;
|
|
2867
|
-
// Snapshot the existing progress block BEFORE the transform so we can
|
|
2868
|
-
// restore it when resync is false.
|
|
2869
|
-
const preFm = resync ? null : extractFrontmatter(originalContent, statePath);
|
|
3066
|
+
const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
|
|
2870
3067
|
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
2871
3068
|
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
2872
3069
|
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
@@ -2877,8 +3074,24 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2877
3074
|
// above (null when resync:true) — these are independent snapshots.
|
|
2878
3075
|
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
2879
3076
|
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
2880
|
-
const preBody = stripFrontmatter(originalContent);
|
|
2881
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);
|
|
2882
3095
|
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
2883
3096
|
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
2884
3097
|
// mirroring buildStateFrontmatter's sessionBodyScope logic.
|
|
@@ -2958,6 +3171,16 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2958
3171
|
status: { pre: preBodyStatus, post: postBodyStatus },
|
|
2959
3172
|
stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
|
|
2960
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 },
|
|
2961
3184
|
};
|
|
2962
3185
|
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
2963
3186
|
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
@@ -2977,11 +3200,34 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2977
3200
|
// callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
|
|
2978
3201
|
// nothing extra and see no change to `synced`/the returned content.
|
|
2979
3202
|
const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
|
|
2980
|
-
|
|
2981
|
-
|
|
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,
|
|
2982
3212
|
deriveProgressKeys: deriveProgressKeys === true,
|
|
2983
3213
|
bodyDeltas,
|
|
3214
|
+
explicitProgressField: explicitProgressField === true,
|
|
2984
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 });
|
|
2985
3231
|
if (divergedFields && preservationInputSnapshot) {
|
|
2986
3232
|
// §8.5's "liberal but visible": every field whose value actually
|
|
2987
3233
|
// differs before vs after `applyStatePreservation` is a field where the
|
|
@@ -2993,10 +3239,13 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2993
3239
|
for (const key of Object.keys(preservation.postFm)) {
|
|
2994
3240
|
const before = preservationInputSnapshot[key];
|
|
2995
3241
|
const after = preservation.postFm[key];
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
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))
|
|
3000
3249
|
divergedFields.push(key);
|
|
3001
3250
|
}
|
|
3002
3251
|
// ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
|
|
@@ -3043,6 +3292,23 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3043
3292
|
}
|
|
3044
3293
|
}
|
|
3045
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
|
+
}
|
|
3046
3312
|
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
3047
3313
|
const body = stripFrontmatter(syncedContent);
|
|
3048
3314
|
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
@@ -3127,6 +3393,12 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
3127
3393
|
authoritativeFm: options?.authoritativeFm,
|
|
3128
3394
|
deriveProgressKeys: options?.deriveProgressKeys === true,
|
|
3129
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,
|
|
3130
3402
|
});
|
|
3131
3403
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
3132
3404
|
return true;
|
|
@@ -3158,15 +3430,36 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
3158
3430
|
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
|
|
3159
3431
|
* `divergedFields` reporting bare `'progress'`).
|
|
3160
3432
|
*/
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
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
|
+
}, {}));
|
|
3170
3463
|
/**
|
|
3171
3464
|
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
|
|
3172
3465
|
* entry. Throws for a `preserve-when-unchanged` field with no
|
|
@@ -3181,9 +3474,19 @@ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
|
|
|
3181
3474
|
* (e.g. `progress`), not a silent degrade.
|
|
3182
3475
|
*/
|
|
3183
3476
|
function bodyLabelFor(field) {
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
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
|
+
}
|
|
3187
3490
|
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3188
3491
|
if (cls && cls.preservation === 'preserve-when-unchanged') {
|
|
3189
3492
|
const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
|
|
@@ -3196,98 +3499,343 @@ function bodyLabelFor(field) {
|
|
|
3196
3499
|
return field;
|
|
3197
3500
|
}
|
|
3198
3501
|
/**
|
|
3199
|
-
* ADR-
|
|
3200
|
-
*
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3203
|
-
*
|
|
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.
|
|
3204
3509
|
*
|
|
3205
|
-
*
|
|
3206
|
-
*
|
|
3207
|
-
*
|
|
3208
|
-
*
|
|
3209
|
-
*
|
|
3210
|
-
*
|
|
3211
|
-
*
|
|
3212
|
-
|
|
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.
|
|
3213
3524
|
*
|
|
3214
|
-
*
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3219
|
-
*
|
|
3220
|
-
*
|
|
3221
|
-
*
|
|
3222
|
-
*
|
|
3223
|
-
*
|
|
3224
|
-
*
|
|
3225
|
-
*
|
|
3226
|
-
*
|
|
3227
|
-
*
|
|
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.
|
|
3228
3540
|
*/
|
|
3229
|
-
function
|
|
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 [];
|
|
3230
3745
|
const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
3231
3746
|
const persistedFm = extractFrontmatter(persisted, statePath);
|
|
3232
3747
|
const persistedBody = stripFrontmatter(persisted);
|
|
3233
|
-
const
|
|
3234
|
-
const
|
|
3235
|
-
// #3471 review: body-FIRST, frontmatter-
|
|
3236
|
-
//
|
|
3237
|
-
//
|
|
3238
|
-
//
|
|
3239
|
-
//
|
|
3240
|
-
//
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
//
|
|
3248
|
-
// `
|
|
3249
|
-
//
|
|
3250
|
-
//
|
|
3251
|
-
// `
|
|
3252
|
-
//
|
|
3253
|
-
// (regression: #1162's "state update is case-insensitive for table field
|
|
3254
|
-
// names").
|
|
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.
|
|
3255
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
|
+
}
|
|
3256
3773
|
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
3257
3774
|
if (bodyValue !== null)
|
|
3258
3775
|
return bodyValue;
|
|
3259
|
-
|
|
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;
|
|
3260
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();
|
|
3261
3825
|
const reconciled = [];
|
|
3262
3826
|
for (const field of reported) {
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
if (
|
|
3827
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
|
|
3828
|
+
continue;
|
|
3829
|
+
if (changed(field)) {
|
|
3830
|
+
seen.add(field);
|
|
3266
3831
|
reconciled.push(field);
|
|
3267
3832
|
}
|
|
3268
3833
|
}
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
// label — Status, Current Plan, Current Phase, ...). #3345's direction
|
|
3272
|
-
// ("preservation restored a field the intent never named") is about a
|
|
3273
|
-
// caller-visible BODY field the transform could plausibly have named —
|
|
3274
|
-
// never about `progress` (`preserve-always`) or `milestone`/`milestone_name`
|
|
3275
|
-
// (`preserve-if-placeholder`), which are structured/paired fields no
|
|
3276
|
-
// caller ever names via a per-field body label and whose restoration is
|
|
3277
|
-
// the long-standing, silent #3242/#948 protection, not a caller-visible
|
|
3278
|
-
// "update". Folding them in unconditionally reported `progress` as
|
|
3279
|
-
// `updated` on every `state.patch`/`state.update` write that happened to
|
|
3280
|
-
// preserve it — even when the call never touched Current Phase's
|
|
3281
|
-
// curated-progress-preserving field at all (regression: #1264's
|
|
3282
|
-
// `state.patch` of `Current Phase` reporting `updated: ['Current Phase',
|
|
3283
|
-
// 'progress']` instead of `['Current Phase']`).
|
|
3284
|
-
for (const field of divergedFields) {
|
|
3285
|
-
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3286
|
-
if (!cls || cls.preservation !== 'preserve-when-unchanged')
|
|
3834
|
+
for (const field of mappedFrontmatterFields) {
|
|
3835
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
|
|
3287
3836
|
continue;
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
reconciled.push(label);
|
|
3837
|
+
seen.add(field);
|
|
3838
|
+
reconciled.push(field);
|
|
3291
3839
|
}
|
|
3292
3840
|
return reconciled;
|
|
3293
3841
|
}
|
|
@@ -3342,11 +3890,21 @@ function cmdStateJson(cwd, raw) {
|
|
|
3342
3890
|
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
3343
3891
|
const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
3344
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;
|
|
3345
3904
|
const unchanged = (v) => ({ pre: v, post: v });
|
|
3346
3905
|
const ctx = {
|
|
3347
|
-
preFm: null,
|
|
3348
3906
|
postFm: built,
|
|
3349
|
-
|
|
3907
|
+
snapshot: existingFm,
|
|
3350
3908
|
resync: true,
|
|
3351
3909
|
deriveProgressKeys: false,
|
|
3352
3910
|
bodyDeltas: {
|
|
@@ -3356,10 +3914,15 @@ function cmdStateJson(cwd, raw) {
|
|
|
3356
3914
|
current_phase: unchanged(bodyCurrentPhase),
|
|
3357
3915
|
current_plan: unchanged(bodyCurrentPlan),
|
|
3358
3916
|
current_phase_name: unchanged(bodyPhaseSource),
|
|
3917
|
+
last_activity_desc: unchanged(bodyLastActivityDesc),
|
|
3359
3918
|
},
|
|
3360
3919
|
mutated: false,
|
|
3361
3920
|
};
|
|
3362
|
-
|
|
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()) {
|
|
3363
3926
|
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3364
3927
|
if (cls)
|
|
3365
3928
|
stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
|
|
@@ -3412,26 +3975,28 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3412
3975
|
// still runs after the sync; the override is re-asserted after it inside
|
|
3413
3976
|
// readModifyWriteStateMd for layouts with no body `Phase:` line.
|
|
3414
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 = {};
|
|
3415
3981
|
const rmwOptions = {
|
|
3416
3982
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3417
3983
|
divergedFields,
|
|
3984
|
+
preWriteState,
|
|
3418
3985
|
};
|
|
3419
3986
|
let precomputedUpdated = [];
|
|
3420
|
-
let preSyncContent = '';
|
|
3421
3987
|
// #3311: begin-phase is the claim point — it is the one Current Position
|
|
3422
3988
|
// transition that explicitly names its phase, so it both records this
|
|
3423
3989
|
// session's claim and detects a conflicting live claim for a different
|
|
3424
3990
|
// phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
|
|
3425
3991
|
// calls cannot both read "no claim" and both write.
|
|
3426
3992
|
let milestoneConflict = null;
|
|
3427
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
3993
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
3428
3994
|
milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
|
|
3429
3995
|
if (milestoneConflict) {
|
|
3430
3996
|
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
|
|
3431
3997
|
}
|
|
3432
3998
|
const result = transitionCore(content, intent, deps);
|
|
3433
3999
|
precomputedUpdated = result.updated;
|
|
3434
|
-
preSyncContent = result.content;
|
|
3435
4000
|
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
|
|
3436
4001
|
// the intent-first override must not fire — it would drift frontmatter
|
|
3437
4002
|
// away from the preserved body value. Dropping it here is safe because
|
|
@@ -3445,8 +4010,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3445
4010
|
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
|
3446
4011
|
// field preservation restored that this transform never touched (#3345's
|
|
3447
4012
|
// direction).
|
|
3448
|
-
const updated = reconcileReportedFields(statePath,
|
|
4013
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
3449
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);
|
|
3450
4036
|
}
|
|
3451
4037
|
/**
|
|
3452
4038
|
* Write a WAITING.json signal file when GSD hits a decision point.
|
|
@@ -3698,19 +4284,38 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3698
4284
|
// line, and the prose re-derivation of current_phase_name truncates names
|
|
3699
4285
|
// that themselves contain a parenthetical — the authoritative override keeps
|
|
3700
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.
|
|
3701
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 = {};
|
|
3702
4301
|
const rmwOptions = {
|
|
3703
4302
|
resync: false,
|
|
3704
4303
|
deriveProgressKeys: true,
|
|
3705
4304
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3706
4305
|
divergedFields,
|
|
4306
|
+
preWriteState,
|
|
3707
4307
|
};
|
|
3708
4308
|
let precomputedUpdated = [];
|
|
3709
|
-
let preSyncContent = '';
|
|
3710
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
|
+
}
|
|
3711
4317
|
const result = transitionCore(content, intent, deps);
|
|
3712
4318
|
precomputedUpdated = result.updated;
|
|
3713
|
-
preSyncContent = result.content;
|
|
3714
4319
|
return result.content;
|
|
3715
4320
|
}, cwd, rmwOptions);
|
|
3716
4321
|
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
|
@@ -3719,11 +4324,35 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3719
4324
|
// (#3345's direction) — traced for this phase (design doc: "not traced in
|
|
3720
4325
|
// the analysis pass") and found to need exactly the same treatment as
|
|
3721
4326
|
// `cmdStateBeginPhase`.
|
|
3722
|
-
const updated = reconcileReportedFields(statePath,
|
|
4327
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
3723
4328
|
const result = updated.length === 0
|
|
3724
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.).' }
|
|
3725
4330
|
: { updated, phase: phaseNumber, plan_count: planCount };
|
|
3726
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);
|
|
3727
4356
|
}
|
|
3728
4357
|
/**
|
|
3729
4358
|
* Bug #2630: reset STATE.md for a new milestone cycle.
|
|
@@ -3746,16 +4375,24 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
3746
4375
|
// steady-state syncStateFrontmatter post-sync.
|
|
3747
4376
|
const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
|
|
3748
4377
|
const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
|
|
4378
|
+
let switched = false;
|
|
3749
4379
|
const lockPath = acquireStateLock(statePath);
|
|
3750
4380
|
try {
|
|
3751
4381
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
3752
4382
|
const result = transitionCore(content, intent, deps);
|
|
3753
4383
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
|
|
3754
4384
|
output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
|
|
4385
|
+
switched = true;
|
|
3755
4386
|
}
|
|
3756
4387
|
finally {
|
|
3757
4388
|
releaseStateLock(lockPath);
|
|
3758
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);
|
|
3759
4396
|
}
|
|
3760
4397
|
/**
|
|
3761
4398
|
* Gate 1: Validate STATE.md against filesystem.
|
|
@@ -3851,10 +4488,31 @@ function readStateFrontmatterScoped(content, statePath) {
|
|
|
3851
4488
|
function stateDiagnostic(code, severity, message, advice) {
|
|
3852
4489
|
return { code, severity, message, remedy: adviseRemedy(advice) };
|
|
3853
4490
|
}
|
|
3854
|
-
function cmdStateValidate(cwd, raw) {
|
|
4491
|
+
function cmdStateValidate(cwd, raw, opts = {}) {
|
|
3855
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
|
+
};
|
|
3856
4514
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
3857
|
-
|
|
4515
|
+
emit({ error: 'STATE.md not found' });
|
|
3858
4516
|
return;
|
|
3859
4517
|
}
|
|
3860
4518
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
@@ -3867,10 +4525,10 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3867
4525
|
// unconditionally and returned immediately, matching every other
|
|
3868
4526
|
// error-class code, not a mere warning). Message reused verbatim from
|
|
3869
4527
|
// `textEncodingError`, not paraphrased.
|
|
3870
|
-
|
|
4528
|
+
emit({
|
|
3871
4529
|
valid: false,
|
|
3872
4530
|
warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
|
|
3873
|
-
}
|
|
4531
|
+
});
|
|
3874
4532
|
return;
|
|
3875
4533
|
}
|
|
3876
4534
|
const warnings = [];
|
|
@@ -3888,7 +4546,7 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3888
4546
|
const phasesDir = planningPaths(cwd).phases;
|
|
3889
4547
|
if (currentPhase === null) {
|
|
3890
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'));
|
|
3891
|
-
|
|
4549
|
+
emit({ valid: false, warnings, scope });
|
|
3892
4550
|
return;
|
|
3893
4551
|
}
|
|
3894
4552
|
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
|
@@ -3897,7 +4555,7 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3897
4555
|
}
|
|
3898
4556
|
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
3899
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'));
|
|
3900
|
-
|
|
4558
|
+
emit({ valid: false, warnings, scope });
|
|
3901
4559
|
return;
|
|
3902
4560
|
}
|
|
3903
4561
|
let phaseDirPath;
|
|
@@ -3906,14 +4564,14 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3906
4564
|
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
|
|
3907
4565
|
if (!phaseDir) {
|
|
3908
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'));
|
|
3909
|
-
|
|
4567
|
+
emit({ valid: false, warnings, scope });
|
|
3910
4568
|
return;
|
|
3911
4569
|
}
|
|
3912
4570
|
phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
|
|
3913
4571
|
}
|
|
3914
4572
|
catch {
|
|
3915
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'));
|
|
3916
|
-
|
|
4574
|
+
emit({ valid: false, warnings, scope });
|
|
3917
4575
|
return;
|
|
3918
4576
|
}
|
|
3919
4577
|
try {
|
|
@@ -3981,8 +4639,56 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3981
4639
|
catch {
|
|
3982
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'));
|
|
3983
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'));
|
|
4688
|
+
}
|
|
4689
|
+
}
|
|
3984
4690
|
const valid = warnings.length === 0;
|
|
3985
|
-
|
|
4691
|
+
emit({ valid, warnings, scope });
|
|
3986
4692
|
}
|
|
3987
4693
|
/**
|
|
3988
4694
|
* Gate 2: Sync STATE.md from filesystem ground truth.
|
|
@@ -3997,6 +4703,19 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
3997
4703
|
}
|
|
3998
4704
|
const verify = options && options.verify;
|
|
3999
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);
|
|
4000
4719
|
const changes = [];
|
|
4001
4720
|
let modified = content;
|
|
4002
4721
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -4147,12 +4866,30 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4147
4866
|
modified = syncResult.content;
|
|
4148
4867
|
const coreChanges = syncResult.data?.changes ?? [];
|
|
4149
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
|
+
}
|
|
4150
4882
|
if (verify) {
|
|
4151
4883
|
output({ synced: false, changes, dry_run: true }, raw, undefined);
|
|
4152
4884
|
return;
|
|
4153
4885
|
}
|
|
4154
|
-
if (
|
|
4155
|
-
|
|
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);
|
|
4156
4893
|
}
|
|
4157
4894
|
output({ synced: true, changes, dry_run: false }, raw, undefined);
|
|
4158
4895
|
}
|
|
@@ -4494,16 +5231,38 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
4494
5231
|
// flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
|
|
4495
5232
|
// below, right before `output()`.
|
|
4496
5233
|
const updated = [];
|
|
4497
|
-
let preSyncContent = '';
|
|
4498
5234
|
const divergedFields = [];
|
|
4499
|
-
|
|
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) => {
|
|
4500
5250
|
const currentPhase = resolvedPhase;
|
|
4501
5251
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
4502
5252
|
// cannot shadow the body Status field (pipe-table or inline).
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
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
|
+
}
|
|
4507
5266
|
// Update Status field (body only — #1255)
|
|
4508
5267
|
const statusValue = `Phase ${currentPhase} complete`;
|
|
4509
5268
|
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
@@ -4584,10 +5343,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
4584
5343
|
updated.push({ kind: 'section', name: 'Current Position' });
|
|
4585
5344
|
}
|
|
4586
5345
|
}
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
return out;
|
|
4590
|
-
}, cwd, { divergedFields });
|
|
5346
|
+
return reassemble(body);
|
|
5347
|
+
}, cwd, rmwOptions);
|
|
4591
5348
|
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
|
|
4592
5349
|
// the analysis pass"). Unlike the transitionCore-based commands, this
|
|
4593
5350
|
// adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
|
|
@@ -4603,8 +5360,17 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
4603
5360
|
// string-matching a name against a Set.
|
|
4604
5361
|
const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
|
|
4605
5362
|
const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
|
|
4606
|
-
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath,
|
|
5363
|
+
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preWriteState, fieldEntries, divergedFields)];
|
|
4607
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);
|
|
4608
5374
|
}
|
|
4609
5375
|
module.exports = {
|
|
4610
5376
|
stateExtractField: state_document_cjs_1.stateExtractField,
|
|
@@ -4659,6 +5425,23 @@ module.exports = {
|
|
|
4659
5425
|
// FIELD_CLASSIFICATION, exposed so a parity test can pin that every
|
|
4660
5426
|
// `preserve-when-unchanged` row has a label here.
|
|
4661
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,
|
|
4662
5445
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
4663
5446
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
4664
5447
|
_setLockProbes(probes) {
|