@opengsd/gsd-core 1.11.0 → 1.13.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/.opencode/plugins/gsd-core.js +12 -0
- 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 +78 -42
- 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 +91 -112
- package/agents/gsd-planner.md +20 -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 +82 -7
- package/agents/gsd-ui-researcher.md +70 -3
- package/agents/gsd-verifier.md +24 -2
- package/bin/install.js +847 -200
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/quick.md +8 -4
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +761 -100
- 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-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +785 -144
- package/gsd-core/bin/lib/capability-state.cjs +25 -4
- package/gsd-core/bin/lib/capability-validator.cjs +321 -18
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +229 -6
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- 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-aliases.cjs +16 -0
- 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 +877 -54
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +121 -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 +118 -14
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +23 -2
- 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/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +975 -326
- package/gsd-core/bin/lib/gap-checker.cjs +41 -8
- package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- 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 +96 -11
- package/gsd-core/bin/lib/init-command-router.cjs +132 -21
- package/gsd-core/bin/lib/init.cjs +252 -56
- package/gsd-core/bin/lib/install-engine.cjs +252 -15
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
- package/gsd-core/bin/lib/install-profiles.cjs +100 -18
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -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 +195 -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/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +175 -4
- package/gsd-core/bin/lib/milestone.cjs +112 -7
- 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-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +321 -13
- 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 +1175 -115
- package/gsd-core/bin/lib/plan-document.cjs +273 -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 +165 -34
- package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
- 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/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- 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 +62 -14
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
- package/gsd-core/bin/lib/roadmap.cjs +248 -64
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
- 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 +75 -8
- package/gsd-core/bin/lib/smart-entry.cjs +19 -31
- 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 +216 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
- package/gsd-core/bin/lib/state-transition.cjs +850 -145
- package/gsd-core/bin/lib/state.cjs +1629 -287
- package/gsd-core/bin/lib/surface.cjs +33 -10
- 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/tdd-red-evidence.cjs +133 -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 +2542 -387
- 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/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- 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-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -13
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +441 -56
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- 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 +185 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +13 -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/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +6 -5
- 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 +17 -5
- package/gsd-core/references/execute-mvp-tdd.md +18 -18
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- 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/mvp-concepts.md +2 -2
- 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/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- 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-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +76 -3
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +39 -9
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/tdd.md +15 -12
- 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/verifier-evidence-gate.md +160 -0
- 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 +7 -0
- package/gsd-core/templates/state.md +7 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +3 -1
- package/gsd-core/workflows/add-phase.md +5 -3
- package/gsd-core/workflows/add-tests.md +4 -9
- package/gsd-core/workflows/add-todo.md +2 -2
- package/gsd-core/workflows/ai-integration-phase.md +5 -10
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +14 -3
- package/gsd-core/workflows/audit-milestone.md +11 -9
- package/gsd-core/workflows/audit-uat.md +19 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +12 -26
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/cleanup.md +3 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
- package/gsd-core/workflows/code-review-fix.md +3 -1
- package/gsd-core/workflows/code-review.md +192 -69
- package/gsd-core/workflows/complete-milestone.md +28 -14
- package/gsd-core/workflows/debug.md +6 -4
- package/gsd-core/workflows/diagnose-issues.md +17 -7
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +46 -19
- package/gsd-core/workflows/docs-update.md +6 -5
- package/gsd-core/workflows/edit-phase.md +3 -1
- package/gsd-core/workflows/eval-review.md +5 -10
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -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 +29 -5
- 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 +4 -2
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +68 -66
- package/gsd-core/workflows/execute-plan.md +25 -20
- package/gsd-core/workflows/explore.md +3 -1
- package/gsd-core/workflows/extract-learnings.md +3 -1
- package/gsd-core/workflows/fast.md +8 -2
- package/gsd-core/workflows/forensics.md +3 -1
- package/gsd-core/workflows/graduation.md +6 -6
- package/gsd-core/workflows/health.md +4 -7
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +17 -14
- package/gsd-core/workflows/inbox.md +5 -6
- package/gsd-core/workflows/ingest-docs.md +45 -12
- package/gsd-core/workflows/insert-phase.md +7 -5
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +7 -3
- package/gsd-core/workflows/list-workspaces.md +3 -1
- package/gsd-core/workflows/manager.md +15 -26
- package/gsd-core/workflows/map-codebase.md +3 -1
- package/gsd-core/workflows/milestone-summary.md +3 -1
- package/gsd-core/workflows/mvp-phase.md +3 -3
- package/gsd-core/workflows/new-milestone.md +10 -22
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +17 -29
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +4 -2
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +20 -5
- 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 +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
- package/gsd-core/workflows/plan-phase.md +251 -54
- package/gsd-core/workflows/plan-review-convergence.md +148 -19
- package/gsd-core/workflows/plant-seed.md +3 -3
- package/gsd-core/workflows/pr-branch.md +195 -51
- package/gsd-core/workflows/profile-user.md +17 -15
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +52 -15
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +33 -32
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +6 -4
- package/gsd-core/workflows/remove-workspace.md +3 -3
- package/gsd-core/workflows/resume-project.md +14 -14
- package/gsd-core/workflows/review.md +404 -21
- package/gsd-core/workflows/scan.md +3 -1
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +9 -9
- package/gsd-core/workflows/settings-integrations.md +66 -32
- package/gsd-core/workflows/settings.md +4 -6
- package/gsd-core/workflows/ship.md +22 -16
- package/gsd-core/workflows/sketch-wrap-up.md +13 -17
- package/gsd-core/workflows/sketch.md +13 -19
- package/gsd-core/workflows/smart-entry.md +4 -6
- package/gsd-core/workflows/spec-phase.md +31 -4
- package/gsd-core/workflows/spike-wrap-up.md +9 -11
- package/gsd-core/workflows/spike.md +21 -32
- package/gsd-core/workflows/stats.md +4 -2
- package/gsd-core/workflows/sync-skills.md +13 -5
- package/gsd-core/workflows/thread.md +13 -7
- package/gsd-core/workflows/transition.md +7 -5
- package/gsd-core/workflows/ui-phase.md +36 -21
- package/gsd-core/workflows/ui-review.md +7 -11
- package/gsd-core/workflows/ultraplan-phase.md +7 -13
- package/gsd-core/workflows/undo.md +9 -17
- package/gsd-core/workflows/update.md +47 -48
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +106 -21
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +302 -22
- 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 +77 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +46 -12
- package/hooks/dist/gsd-read-guard.js +18 -7
- package/hooks/dist/gsd-read-injection-scanner.js +22 -13
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +222 -29
- package/hooks/dist/gsd-validate-commit.sh +523 -12
- 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 +36 -17
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +4 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +302 -22
- 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 +77 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +46 -12
- package/hooks/gsd-read-guard.js +18 -7
- package/hooks/gsd-read-injection-scanner.js +22 -13
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +222 -29
- package/hooks/gsd-validate-commit.sh +523 -12
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +36 -17
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/hooks.json +6 -0
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +4 -0
- package/package.json +14 -9
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +12 -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 +52 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +406 -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 +189 -4
- 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/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -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 +45 -14
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-removed-but-needed.cjs +184 -16
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +124 -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/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +83 -14
- package/scripts/require-issue-link-policy.cjs +16 -1
- 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-execute-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- 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
|
@@ -14,27 +14,51 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
14
14
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
16
|
const ioMod = require("./io.cjs");
|
|
17
|
-
const { output, error } = ioMod;
|
|
17
|
+
const { output, error, declineNoOp, formatDiagnosticToken } = ioMod;
|
|
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;
|
|
18
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
|
|
22
28
|
const phaseIdMod = require("./phase-id.cjs");
|
|
23
|
-
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, isSentinelPhaseId, scopeToPhase,
|
|
29
|
+
const { parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, matchPhaseDirs, phaseKeyFromToken, phaseKeyFromDir, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase,
|
|
30
|
+
// #2761 M3: owns the bracket milestone intro and canonical pad2 spelling.
|
|
31
|
+
bracketMilestoneIntroSrcFor, } = phaseIdMod;
|
|
24
32
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
33
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
26
|
-
|
|
34
|
+
// #3642: hasMilestoneSectioning no longer consumed here — its >=2 semantics answered sibling conflation, but this branch asks asserted-vs-section (>=1). It stays exported from roadmap-parser.cjs for its unit pins.
|
|
35
|
+
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasAnyMilestoneSection } = roadmapParserMod;
|
|
27
36
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
28
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
38
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
30
|
-
const { planningDir, planningPaths } = planningWorkspace;
|
|
39
|
+
const { planningDir, planningPaths, resolvePhaseIdConvention } = planningWorkspace;
|
|
31
40
|
const clock_cjs_1 = require("./clock.cjs");
|
|
32
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
42
|
const frontmatter = require("./frontmatter.cjs");
|
|
34
|
-
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter;
|
|
43
|
+
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel, FRONTMATTER_UNPARSEABLE } = frontmatter;
|
|
44
|
+
/**
|
|
45
|
+
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
|
|
46
|
+
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a frontmatter-fenced region
|
|
47
|
+
* exists but failed to parse (malformed YAML, or a refused anchor/alias/merge key)? Mirrors
|
|
48
|
+
* `state-transition.cts`'s private helper of the same name/shape — kept local rather than
|
|
49
|
+
* exported+imported because the two modules' `existingFm` values come from independent
|
|
50
|
+
* `extractFrontmatter` calls and this predicate is a two-line symbol read, not shared state.
|
|
51
|
+
*/
|
|
52
|
+
function isUnparseableFrontmatter(existingFm) {
|
|
53
|
+
return existingFm[FRONTMATTER_UNPARSEABLE] === true;
|
|
54
|
+
}
|
|
35
55
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
56
|
const scanPhasePlans = require("./plan-scan.cjs");
|
|
37
57
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
58
|
+
const coreUtilsMod = require("./core-utils.cjs");
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
60
|
+
const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
62
|
const verificationMod = require("./verification.cjs");
|
|
39
63
|
const { isPhaseComplete } = verificationMod;
|
|
40
64
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -45,6 +69,10 @@ const phaseLocatorMod = require("./phase-locator.cjs");
|
|
|
45
69
|
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
46
70
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
71
|
const stateTransitionMod = require("./state-transition.cjs");
|
|
72
|
+
// #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a
|
|
73
|
+
// projection of this leaf schema rather than a hand-maintained literal.
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
75
|
+
const stateMdSchemaMod = require("./state-md-schema.cjs");
|
|
48
76
|
// #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
|
|
49
77
|
// node builtins, so it introduces no cycle on this path.
|
|
50
78
|
const project_root_cjs_1 = require("./project-root.cjs");
|
|
@@ -53,7 +81,13 @@ const project_root_cjs_1 = require("./project-root.cjs");
|
|
|
53
81
|
// it introduces no cycle on this path.
|
|
54
82
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
55
83
|
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
56
|
-
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
|
84
|
+
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection, stateReplaceProgressPercent, formatProgressMachineSegment } = stateTransitionMod;
|
|
85
|
+
// #3699: the frontmatter-key <-> body-field routing behind `state update`'s
|
|
86
|
+
// failure explanation, and the classification table it falls back to.
|
|
87
|
+
const { getFieldClassification, getFrontmatterBodySource, frontmatterKeyForBodyField } = stateTransitionMod;
|
|
88
|
+
// ADR-3473 §8.7 (#3872): the declared dotted-leaf enumeration `reconcileReportedFields`
|
|
89
|
+
// diffs against — see `declaredLeavesOf` below.
|
|
90
|
+
const { FIELD_CLASSIFICATION } = stateTransitionMod;
|
|
57
91
|
const state_document_cjs_1 = require("./state-document.cjs");
|
|
58
92
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
59
93
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
@@ -233,7 +267,7 @@ function cmdStateLoad(cwd, raw) {
|
|
|
233
267
|
`state_exists=${stateExists}`,
|
|
234
268
|
];
|
|
235
269
|
process.stdout.write(lines.join('\n'));
|
|
236
|
-
|
|
270
|
+
throw new ExitError(0);
|
|
237
271
|
}
|
|
238
272
|
output(result, false, undefined);
|
|
239
273
|
}
|
|
@@ -312,14 +346,15 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
312
346
|
// delta (table-driven) that this phase adds. Field-name validation (security)
|
|
313
347
|
// and the resync-progress decision stay in this adapter.
|
|
314
348
|
let precomputed = { updated: [], failed: [] };
|
|
315
|
-
let preSyncContent = '';
|
|
316
349
|
const divergedFields = [];
|
|
350
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
351
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
352
|
+
const preWriteState = {};
|
|
317
353
|
readModifyWriteStateMd(statePath, (content) => {
|
|
318
354
|
const result = transitionCore(content, { kind: 'patch', patches }, { clock: clock_cjs_1.realClock });
|
|
319
355
|
precomputed = result.data ?? precomputed;
|
|
320
|
-
preSyncContent = result.content;
|
|
321
356
|
return result.content;
|
|
322
|
-
}, cwd, { resync: shouldResync, divergedFields });
|
|
357
|
+
}, cwd, { resync: shouldResync, divergedFields, explicitProgressField: shouldResync, preWriteState });
|
|
323
358
|
// ADR-3408 §8.4 (D4, fix(#3351) generalized — see `reconcileReportedFields`):
|
|
324
359
|
// patchCore's bookkeeping says whether the stateReplaceField text-replace
|
|
325
360
|
// MATCHED — but its plain-line pattern (`m` flag over the full document)
|
|
@@ -334,7 +369,7 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
334
369
|
// `applyStatePreservation` restored that this patch never named at all
|
|
335
370
|
// (#3345's direction), a case the pre-#3471 version of this command never
|
|
336
371
|
// covered.
|
|
337
|
-
const updated = reconcileReportedFields(statePath,
|
|
372
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputed.updated, divergedFields);
|
|
338
373
|
const updatedSet = new Set(updated);
|
|
339
374
|
const failed = Object.keys(patches).filter((field) => !updatedSet.has(field));
|
|
340
375
|
const results = { updated, failed };
|
|
@@ -344,6 +379,53 @@ function cmdStatePatch(cwd, patches, raw) {
|
|
|
344
379
|
error('STATE.md not found');
|
|
345
380
|
}
|
|
346
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Why did `state update <field>` not write anything?
|
|
384
|
+
*
|
|
385
|
+
* #3699: this used to be one sentence — `Field "X" not found in STATE.md` — for
|
|
386
|
+
* every falsy outcome, so a PRESENT-but-derived frontmatter key and a genuinely
|
|
387
|
+
* absent field produced byte-identical output apart from the name. The classifier
|
|
388
|
+
* already knew the difference; the message threw it away, and worse, pointed away
|
|
389
|
+
* from the route that works.
|
|
390
|
+
*
|
|
391
|
+
* Four distinct answers, because there are four distinct situations:
|
|
392
|
+
* 1. a body-derived frontmatter key whose body source EXISTS → name that source
|
|
393
|
+
* 2. a frontmatter key with no body source at all (disk/external/clock-derived)
|
|
394
|
+
* → say what derives it, and do not invent a body field to blame
|
|
395
|
+
* 3. a body field that feeds a frontmatter key still carrying a value
|
|
396
|
+
* → name the key, so case D is diagnosable rather than a bare absence
|
|
397
|
+
* 4. genuinely unknown → unchanged
|
|
398
|
+
*/
|
|
399
|
+
function explainUpdateFailure(field) {
|
|
400
|
+
const bodySource = getFrontmatterBodySource(field);
|
|
401
|
+
if (bodySource) {
|
|
402
|
+
// (1) The fallback in `updateCore` did not fire, so a body source line
|
|
403
|
+
// exists — that is the writable route.
|
|
404
|
+
const [primary] = bodySource;
|
|
405
|
+
return `Field "${field}" is a body-derived frontmatter key and is not directly writable. `
|
|
406
|
+
+ `Update its body source instead: state update "${primary}" <value>.`;
|
|
407
|
+
}
|
|
408
|
+
const classification = getFieldClassification(field);
|
|
409
|
+
if (classification) {
|
|
410
|
+
// (2) Known key, no body source: disk/external/free-derived.
|
|
411
|
+
const derivedFrom = {
|
|
412
|
+
disk: 'derived from a scan of .planning/phases/ and is not directly writable',
|
|
413
|
+
external: 'derived from ROADMAP.md and is not directly writable',
|
|
414
|
+
free: 'recomputed on every write and is not directly writable',
|
|
415
|
+
curated: 'maintained by the write path and is not directly writable through this command',
|
|
416
|
+
body: 'body-derived and is not directly writable',
|
|
417
|
+
};
|
|
418
|
+
return `Field "${field}" is a frontmatter key that is ${derivedFrom[classification.source]}.`;
|
|
419
|
+
}
|
|
420
|
+
const owningKey = frontmatterKeyForBodyField(field);
|
|
421
|
+
if (owningKey) {
|
|
422
|
+
// (3) Case D from the body-field side.
|
|
423
|
+
return `Field "${field}" not found in STATE.md. It is the body source for frontmatter key `
|
|
424
|
+
+ `"${owningKey}" — add the "${field}:" line to the body, or update "${owningKey}" directly `
|
|
425
|
+
+ `to repair a document whose body source is missing.`;
|
|
426
|
+
}
|
|
427
|
+
return `Field "${field}" not found in STATE.md`; // (4) genuinely unknown
|
|
428
|
+
}
|
|
347
429
|
function cmdStateUpdate(cwd, field, value) {
|
|
348
430
|
if (!field || value === undefined) {
|
|
349
431
|
error('field and value required for state update');
|
|
@@ -358,7 +440,30 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
358
440
|
const statePath = planningPaths(cwd).state;
|
|
359
441
|
try {
|
|
360
442
|
let updated = false;
|
|
361
|
-
|
|
443
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
444
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
445
|
+
const preWriteState = {};
|
|
446
|
+
let transitionData;
|
|
447
|
+
// #3699 case D: when `updateCore` falls back to writing the frontmatter key
|
|
448
|
+
// directly, the value must survive the post-sync pass — otherwise the write
|
|
449
|
+
// is silently undone. `buildStateFrontmatter` re-derives `stopped_at` from
|
|
450
|
+
// the body, finds no source, and emits nothing; `applyPreserveWhenUnchanged`
|
|
451
|
+
// then sees an unchanged (absent) body source and restores the PRE-write
|
|
452
|
+
// snapshot over the value just written. Verified: without this the command
|
|
453
|
+
// reported `updated:false` with `preserved:["Stopped At"]` and the old value
|
|
454
|
+
// stood.
|
|
455
|
+
//
|
|
456
|
+
// `authoritativeFm` is the seam built for exactly this (#2736 — "intent-first
|
|
457
|
+
// frontmatter values … so the lossy body-prose re-derivation can never
|
|
458
|
+
// destroy information the transition just resolved"), and it is re-applied
|
|
459
|
+
// AFTER preservation (`applyPostSyncPreservation`), so it wins the restore.
|
|
460
|
+
//
|
|
461
|
+
// Populated by the transform below rather than up front, because only the
|
|
462
|
+
// transition knows whether the fallback fired. Safe: `readModifyWriteStateMd`
|
|
463
|
+
// dereferences `options.authoritativeFm` after running the transform. Left
|
|
464
|
+
// empty when the fallback does not fire — an empty object iterates zero
|
|
465
|
+
// entries and is a no-op at both application sites.
|
|
466
|
+
const authoritativeFm = {};
|
|
362
467
|
const divergedFields = [];
|
|
363
468
|
const shouldResync = shouldResyncStateProgress([field]);
|
|
364
469
|
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
|
@@ -370,9 +475,12 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
370
475
|
readModifyWriteStateMd(statePath, (content) => {
|
|
371
476
|
const result = transitionCore(content, { kind: 'update', field: field, value: value }, { clock: clock_cjs_1.realClock });
|
|
372
477
|
updated = result.data?.updated === true;
|
|
373
|
-
|
|
478
|
+
transitionData = result.data;
|
|
479
|
+
if (transitionData?.wroteFrontmatter === true) {
|
|
480
|
+
authoritativeFm[field] = value;
|
|
481
|
+
}
|
|
374
482
|
return result.content;
|
|
375
|
-
}, cwd, { resync: shouldResync, divergedFields });
|
|
483
|
+
}, cwd, { resync: shouldResync, divergedFields, authoritativeFm, explicitProgressField: shouldResync, preWriteState });
|
|
376
484
|
// ADR-3408 §8.4 (D4): reconcile against the bytes actually persisted —
|
|
377
485
|
// `updateCore`'s own match does not know whether sync/preservation later
|
|
378
486
|
// discarded the value it wrote (#3351's direction, generalized from
|
|
@@ -380,14 +488,37 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
380
488
|
// restored during this write that this command never touched at all
|
|
381
489
|
// (#3345's direction) — reported separately from `updated` because this
|
|
382
490
|
// command's contract is a single-field boolean, not a per-field array.
|
|
383
|
-
const reconciled = reconcileReportedFields(statePath,
|
|
491
|
+
const reconciled = reconcileReportedFields(statePath, preWriteState, updated ? [field] : [], divergedFields);
|
|
384
492
|
updated = reconciled.includes(field);
|
|
385
493
|
const preserved = reconciled.filter((f) => f !== field);
|
|
386
494
|
if (updated) {
|
|
387
|
-
|
|
495
|
+
// #3699 case D: surfaced so a caller can tell "wrote the body source" from
|
|
496
|
+
// "wrote the frontmatter key because no body source existed" — the second
|
|
497
|
+
// is a repair, and silently reporting it as an ordinary update is the same
|
|
498
|
+
// class of unfalsifiable success this issue is about.
|
|
499
|
+
const wroteFrontmatter = transitionData?.wroteFrontmatter === true;
|
|
500
|
+
if (!wroteFrontmatter) {
|
|
501
|
+
output({ updated: true, preserved }, false, undefined);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// `preserved` reports the BODY LABEL of each field preservation restored
|
|
505
|
+
// (`bodyLabelFor`, the #3345 direction). In the case-D fallback that
|
|
506
|
+
// reading is stale by one step: preservation DID restore this field's
|
|
507
|
+
// snapshot, and `authoritativeFm` then overrode it, so the value on disk
|
|
508
|
+
// is the one just written. Reporting it as preserved would claim a
|
|
509
|
+
// restore that did not survive — the same unfalsifiable-success shape
|
|
510
|
+
// #3699 is about, one field over. Drop this field's own labels; other
|
|
511
|
+
// fields' preservation is untouched and still reported.
|
|
512
|
+
const ownLabels = new Set((getFrontmatterBodySource(field) ?? []).map((l) => l.toLowerCase()));
|
|
513
|
+
output({
|
|
514
|
+
updated: true,
|
|
515
|
+
wrote: 'frontmatter',
|
|
516
|
+
preserved: preserved.filter((p) => !ownLabels.has(p.toLowerCase())),
|
|
517
|
+
}, false, undefined);
|
|
518
|
+
}
|
|
388
519
|
}
|
|
389
520
|
else {
|
|
390
|
-
output({ updated: false, reason:
|
|
521
|
+
output({ updated: false, reason: explainUpdateFailure(field), preserved }, false, undefined);
|
|
391
522
|
}
|
|
392
523
|
}
|
|
393
524
|
catch {
|
|
@@ -395,6 +526,42 @@ function cmdStateUpdate(cwd, field, value) {
|
|
|
395
526
|
}
|
|
396
527
|
}
|
|
397
528
|
// ─── State Progression Engine ────────────────────────────────────────────────
|
|
529
|
+
/**
|
|
530
|
+
* The "I could not read the plan position" message, DERIVED from
|
|
531
|
+
* `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` rather than transcribed
|
|
532
|
+
* beside it.
|
|
533
|
+
*
|
|
534
|
+
* The accepted-shape set had two owners: the parser branches in
|
|
535
|
+
* `advancePlanCore` and an English list hand-written here. Nothing coupled
|
|
536
|
+
* them, so adding a branch left this message stale and removing one left it
|
|
537
|
+
* advertising a shape that errors — and no test could see either. ADR-3473
|
|
538
|
+
* §8.3 is "one implementation per rule"; the schema row is that one owner, and
|
|
539
|
+
* rows 23/24/25 already hold the parser to it.
|
|
540
|
+
*
|
|
541
|
+
* `Plan: N of M` is spelled out separately because there is no schema row for
|
|
542
|
+
* the body-only `Plan` field: `buildStateFrontmatter` never reads it into
|
|
543
|
+
* frontmatter, so it has no `current_*` key to hang a row on. That asymmetry is
|
|
544
|
+
* the schema's, not this function's.
|
|
545
|
+
*/
|
|
546
|
+
function advancePlanShapeError() {
|
|
547
|
+
const shapes = stateMdSchemaMod.STATE_FIELD_SCHEMA['current_plan']?.acceptedShapes ?? [];
|
|
548
|
+
const spellings = shapes.map((shape) => (shape === 'N'
|
|
549
|
+
? '`Current Plan: N` with `Total Plans in Phase: M`'
|
|
550
|
+
: `\`Current Plan: ${shape}\``));
|
|
551
|
+
// The body-only `Plan` field has no schema row to derive from:
|
|
552
|
+
// `buildStateFrontmatter` never reads it into frontmatter, so there is no
|
|
553
|
+
// `current_*` key to hang a row on. Its ONE accepted spelling is named here.
|
|
554
|
+
//
|
|
555
|
+
// `Plan: N` with a `Total Plans in Phase: M` sibling is deliberately NOT
|
|
556
|
+
// listed (#3791 review round 6, M2): the parser does not accept it. A
|
|
557
|
+
// revision of this PR added both the branch and this spelling together, on
|
|
558
|
+
// the reasoning that the message must advertise exactly what the parser
|
|
559
|
+
// accepts. That reasoning still holds — which is why removing the branch
|
|
560
|
+
// removes the spelling in the same commit. The invariant is the lockstep,
|
|
561
|
+
// not the length of the list.
|
|
562
|
+
spellings.push('`Plan: N of M`');
|
|
563
|
+
return `Cannot read the plan position from STATE.md. Expected one of: ${spellings.join(', ')}.`;
|
|
564
|
+
}
|
|
398
565
|
/**
|
|
399
566
|
* Replace a STATE.md field with fallback field name support.
|
|
400
567
|
* Tries `primary` first, then `fallback` (if provided), returns content unchanged
|
|
@@ -416,6 +583,86 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
|
|
|
416
583
|
`This may indicate STATE.md was externally modified or uses an unexpected format.\n`);
|
|
417
584
|
return content;
|
|
418
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* #4067: disk-derived plan-completion answer for advance-plan's phase-complete
|
|
588
|
+
* guard.
|
|
589
|
+
*
|
|
590
|
+
* `advancePlanCore` decides "phase complete" purely from STATE.md's scalar plan
|
|
591
|
+
* counter (`currentPlan >= totalPlans`). That counter cannot represent
|
|
592
|
+
* wave-parallel execution — a stale counter carried over from the prior phase
|
|
593
|
+
* (the reported trigger: `Plan: 7 of 7` surviving into a 10-plan phase) or a
|
|
594
|
+
* counter raced by N concurrent executors both let the phase-complete branch
|
|
595
|
+
* fire while sibling plans are mid-flight. This helper answers the completion
|
|
596
|
+
* question from disk instead, exactly the way `state update-progress`
|
|
597
|
+
* recalculates it: every plan in the Current Position phase's directory has a
|
|
598
|
+
* SUMMARY.md.
|
|
599
|
+
*
|
|
600
|
+
* Single-derivation discipline: plan/summary counting is owned by
|
|
601
|
+
* `scanPhasePlans` (src/plan-scan.cts, ADR-3180 §7.5) — this helper consumes
|
|
602
|
+
* it, never re-derives. It deliberately does NOT consult `isPhaseComplete`
|
|
603
|
+
* (§7.4): that owner answers the *verification* question (passing
|
|
604
|
+
* `*-VERIFICATION.md`), a different question from "are all plans executed?".
|
|
605
|
+
* Blocked summaries (#3345) are filtered from the pairing set with the same
|
|
606
|
+
* shared predicate `scanPhasePlans` uses, so the named outstanding list can
|
|
607
|
+
* never disagree with the count-based decision.
|
|
608
|
+
*
|
|
609
|
+
* FAIL-OPEN contract: returns `null` when the disk answer is UNAVAILABLE — no
|
|
610
|
+
* readable phases dir, no directory matching the position phase, or a scan
|
|
611
|
+
* whose scope is not COMPLETE (the scan may be blind to plans it knows exist).
|
|
612
|
+
* `null` means "the caller must fall back to the counter-derived decision",
|
|
613
|
+
* NOT "plans are outstanding"; worlds the seam cannot see (STATE.md with no
|
|
614
|
+
* Current Position `Phase:` line, milestone-archived layouts) keep today's
|
|
615
|
+
* behavior rather than being newly refused.
|
|
616
|
+
*
|
|
617
|
+
* Returns `{ dir, outstanding, planCount, summaryCount }` where `outstanding`
|
|
618
|
+
* is empty when every plan on disk is summarized (vacuously so for a zero-plan
|
|
619
|
+
* phase — #3168's zero-plan-phase posture). `planCount`/`summaryCount` are the
|
|
620
|
+
* countable disk facts behind `outstanding` (live plan files; summaries after
|
|
621
|
+
* the #3345 blocked filter) — #4093's recovery decline reports them to a
|
|
622
|
+
* caller whose STATE.md has lost its labeled plan position, so the suggested
|
|
623
|
+
* repair values are computed from the SAME set `outstanding` was, and can
|
|
624
|
+
* never disagree with a count-based decision either.
|
|
625
|
+
*/
|
|
626
|
+
function scanOutstanding(phasesDir, dir) {
|
|
627
|
+
const phaseDirPath = node_path_1.default.join(phasesDir, dir);
|
|
628
|
+
const scan = scanPhasePlans(phaseDirPath);
|
|
629
|
+
if (scan.scope !== SCOPE.COMPLETE)
|
|
630
|
+
return null;
|
|
631
|
+
// Blocked summaries (#3345) are filtered with the same shared predicate
|
|
632
|
+
// scanPhasePlans uses for its own count, so the named outstanding list can
|
|
633
|
+
// never disagree with a count-based decision.
|
|
634
|
+
const countableSummaries = scan.summaryFiles.filter((f) => !planDependencyGraphMod.isSummaryFileBlocked(node_path_1.default.join(phaseDirPath, f)));
|
|
635
|
+
const outstanding = coreUtilsMod.findUnsummarizedPlans(scan.planFiles, countableSummaries);
|
|
636
|
+
return { dir, outstanding, planCount: scan.planFiles.length, summaryCount: countableSummaries.length };
|
|
637
|
+
}
|
|
638
|
+
function unsummarizedPlansForPositionPhase(cwd, positionPhase) {
|
|
639
|
+
const phasesDir = planningPaths(cwd).phases;
|
|
640
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories exist" is owned by
|
|
641
|
+
// listMilestonePhaseDirs — no hand-rolled readdirSync here. The owner
|
|
642
|
+
// handles an absent phasesDir as a real empty and refuses sentinels.
|
|
643
|
+
//
|
|
644
|
+
// Two passes, narrowest first: the CURRENT-MILESTONE window (so an archived
|
|
645
|
+
// milestone's stale `01-*` directory cannot shadow the live one), then —
|
|
646
|
+
// only when the window cannot answer (no bounded ROADMAP, or the position
|
|
647
|
+
// phase is simply not in it) — an unscoped read, which the owner documents
|
|
648
|
+
// as a real answer. This is a lookup of ONE phase token STATE.md names, not
|
|
649
|
+
// a milestone enumeration, so the unscoped retry is in-contract.
|
|
650
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
651
|
+
const windowed = listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: convention });
|
|
652
|
+
const candidateDirs = windowed.scope === SCOPE.COMPLETE ? windowed.value : [];
|
|
653
|
+
// Canonical phase-token → directory matching (phase-id owner, #2562): both
|
|
654
|
+
// sides of the comparison derived by the same function, never a local regex.
|
|
655
|
+
const { matches } = matchPhaseDirs(candidateDirs, positionPhase, convention);
|
|
656
|
+
if (matches.length > 0)
|
|
657
|
+
return scanOutstanding(phasesDir, matches[0]);
|
|
658
|
+
const unscoped = listMilestonePhaseDirs(phasesDir);
|
|
659
|
+
if (unscoped.scope !== SCOPE.COMPLETE)
|
|
660
|
+
return null;
|
|
661
|
+
const retry = matchPhaseDirs(unscoped.value, positionPhase, convention);
|
|
662
|
+
if (retry.matches.length === 0)
|
|
663
|
+
return null;
|
|
664
|
+
return scanOutstanding(phasesDir, retry.matches[0]);
|
|
665
|
+
}
|
|
419
666
|
function cmdStateAdvancePlan(cwd, raw) {
|
|
420
667
|
const statePath = planningPaths(cwd).state;
|
|
421
668
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
@@ -434,13 +681,25 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
434
681
|
};
|
|
435
682
|
let resultData;
|
|
436
683
|
let precomputedUpdated = [];
|
|
437
|
-
let preSyncContent = '';
|
|
438
684
|
const divergedFields = [];
|
|
685
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
686
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
687
|
+
const preWriteState = {};
|
|
439
688
|
// #3311: the milestone (phase + session) claim is consulted INSIDE the
|
|
440
689
|
// STATE.md lock, so the position read and the claim read cannot interleave
|
|
441
690
|
// with another session's Current Position write.
|
|
442
691
|
let milestoneConflict = null;
|
|
443
|
-
|
|
692
|
+
// #4067: set when the disk-derived guard declines the phase-complete branch —
|
|
693
|
+
// named here so the post-lock output path can report it without re-deriving.
|
|
694
|
+
// Holder (not a bare let) so TypeScript's closure-unaware narrowing cannot
|
|
695
|
+
// collapse the post-lock read to `never` — the callback assigns it.
|
|
696
|
+
const outstandingRef = { value: null };
|
|
697
|
+
// #4093: the position phase token the callback resolved (Current Position
|
|
698
|
+
// `Phase:` line first, frontmatter `current_phase` as fallback), carried out
|
|
699
|
+
// so the generic parse-failure decline can derive recovery facts from disk
|
|
700
|
+
// without re-reading STATE.md outside the lock. Same holder idiom as above.
|
|
701
|
+
const positionPhaseRef = { value: null };
|
|
702
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
444
703
|
// advance-plan has no phase argument of its own — the phase it advances is
|
|
445
704
|
// whatever ## Current Position names. Compare that against the milestone
|
|
446
705
|
// claim: a mismatch means another session moved the single-slot position
|
|
@@ -449,6 +708,17 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
449
708
|
const body = stripFrontmatter(content);
|
|
450
709
|
const positionScope = matchCurrentPositionSection(body) ?? body;
|
|
451
710
|
const positionPhase = parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
711
|
+
// #4093: a Current Position section with ZERO labeled fields has no
|
|
712
|
+
// `Phase:` line either; frontmatter `current_phase` is the documented
|
|
713
|
+
// survivor of body drift (the reporter's document still carried it, and
|
|
714
|
+
// `buildStateFrontmatter` re-derives it from the body only when the body
|
|
715
|
+
// HAS the line). It feeds the recovery DECLINE only — never a write.
|
|
716
|
+
let fmPhase = null;
|
|
717
|
+
if (positionPhase === null) {
|
|
718
|
+
const fmToken = extractFrontmatter(content, statePath)['current_phase'];
|
|
719
|
+
fmPhase = typeof fmToken === 'string' && fmToken.trim() !== '' ? fmToken.trim() : null;
|
|
720
|
+
}
|
|
721
|
+
positionPhaseRef.value = positionPhase ?? fmPhase;
|
|
452
722
|
if (positionPhase !== null) {
|
|
453
723
|
milestoneConflict = milestoneLockMod.checkMilestonePosition(cwd, positionPhase);
|
|
454
724
|
if (milestoneConflict) {
|
|
@@ -456,13 +726,128 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
456
726
|
}
|
|
457
727
|
}
|
|
458
728
|
const result = transitionCore(content, intent, deps);
|
|
729
|
+
// #4067: the transform's phase-complete branch is decided by STATE.md's
|
|
730
|
+
// scalar plan counter, which can neither carry a stale value across phases
|
|
731
|
+
// nor represent wave-parallel execution. Before letting that branch write
|
|
732
|
+
// "Phase complete — ready for verification", re-decide from disk (the same
|
|
733
|
+
// source state.update-progress recalculates from): every plan in the
|
|
734
|
+
// position phase's directory must have a SUMMARY.md. A non-empty
|
|
735
|
+
// outstanding list declines the ENTIRE write — STATE.md is returned
|
|
736
|
+
// byte-identical, so the decline is idempotent and safe for any number of
|
|
737
|
+
// concurrent callers (the disk answer is re-read under the STATE.md lock
|
|
738
|
+
// each call; the counter stays display-only). `null` (disk answer
|
|
739
|
+
// unavailable) fails open to the counter-derived decision, so every
|
|
740
|
+
// world this seam cannot see keeps today's behavior.
|
|
741
|
+
if (result.data?.['advanced'] === false
|
|
742
|
+
&& result.data?.['reason'] === 'last_plan'
|
|
743
|
+
&& positionPhase !== null) {
|
|
744
|
+
const diskAnswer = unsummarizedPlansForPositionPhase(cwd, positionPhase);
|
|
745
|
+
if (diskAnswer !== null && diskAnswer.outstanding.length > 0) {
|
|
746
|
+
outstandingRef.value = diskAnswer;
|
|
747
|
+
resultData = result.data;
|
|
748
|
+
precomputedUpdated = [];
|
|
749
|
+
return content;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
459
752
|
resultData = result.data;
|
|
460
753
|
precomputedUpdated = result.updated;
|
|
461
|
-
preSyncContent = result.content;
|
|
462
754
|
return result.content;
|
|
463
|
-
}, cwd, { divergedFields });
|
|
755
|
+
}, cwd, { divergedFields, preWriteState });
|
|
756
|
+
// #4067 decline path: plans remain unexecuted on disk. Shaped like the
|
|
757
|
+
// existing `last_plan` decline (advanced:false + machine-readable reason,
|
|
758
|
+
// exit 0) rather than a hard error — the caller did nothing wrong and
|
|
759
|
+
// STATE.md needs no repair; the remaining plans' executors will re-run this
|
|
760
|
+
// command, and the final one finds a fully-summarized phase and completes it.
|
|
761
|
+
const plansOutstanding = outstandingRef.value;
|
|
762
|
+
if (plansOutstanding !== null) {
|
|
763
|
+
declineNoOp(raw, 'advanced', 'plans_outstanding', `state advance-plan skipped — phase-complete declined: ${plansOutstanding.outstanding.length} plan(s) in .planning/phases/${plansOutstanding.dir} have no SUMMARY.md (${plansOutstanding.outstanding.join(', ')}). STATE.md was left unchanged; re-run once every plan has executed and written its summary.`, {
|
|
764
|
+
advanced: false,
|
|
765
|
+
phase_dir: plansOutstanding.dir,
|
|
766
|
+
outstanding_plans: plansOutstanding.outstanding,
|
|
767
|
+
milestone_conflict: milestoneConflict,
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// `!resultData` is a type guard, not a second failure mode: the callback
|
|
772
|
+
// above assigns it unconditionally and only runs once STATE.md is known to
|
|
773
|
+
// exist (the missing-file case returns "STATE.md not found" earlier), and
|
|
774
|
+
// every `advancePlanCore` return path sets `data`. So the message below is
|
|
775
|
+
// the one a caller can actually receive.
|
|
464
776
|
if (!resultData || resultData['error']) {
|
|
465
|
-
|
|
777
|
+
// #3807: a multi-`Phase:` Current Position section carries its own cause
|
|
778
|
+
// and its own remedy (name the candidates; the caller resolves them).
|
|
779
|
+
if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
|
|
780
|
+
output({
|
|
781
|
+
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.',
|
|
782
|
+
reason: resultData['reason'],
|
|
783
|
+
phase_candidates: resultData['phase_candidates'],
|
|
784
|
+
}, raw, undefined);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
// #3791 review round 6 (B1): the document carries both plan-position
|
|
788
|
+
// spellings with DIFFERENT numbers. Same posture as the case above — name
|
|
789
|
+
// the candidates and let the caller resolve them. Advancing either one
|
|
790
|
+
// would write a number into the other that nothing derived for it.
|
|
791
|
+
if (resultData && resultData['reason'] === 'ambiguous_plan_position') {
|
|
792
|
+
output({
|
|
793
|
+
error: 'STATE.md carries two plan positions with different numbers — refusing to advance either. Resolve them to a single current plan and re-run.',
|
|
794
|
+
reason: resultData['reason'],
|
|
795
|
+
plan_candidates: resultData['plan_candidates'],
|
|
796
|
+
}, raw, undefined);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// #4093: the generic terminus — no accepted labeled plan-position shape
|
|
800
|
+
// parsed anywhere in the document (the reporter's case: ## Current
|
|
801
|
+
// Position drifted to pure narrative prose with zero labeled fields).
|
|
802
|
+
// Every OTHER refusal above carries a machine-readable reason and the
|
|
803
|
+
// evidence to act on; this one stranded the caller at a bare sentence
|
|
804
|
+
// with no recovery path. Give it the same posture: a `reason` the caller
|
|
805
|
+
// can branch on, plus — when the position phase can be resolved and its
|
|
806
|
+
// directory scanned — the disk-derived facts and the exact labeled lines
|
|
807
|
+
// to re-insert. Nothing is WRITTEN: STATE.md is returned byte-identical
|
|
808
|
+
// (the callback already returned the original content for this path),
|
|
809
|
+
// so the decline is idempotent and no repair is guessed into the file —
|
|
810
|
+
// the caller (human or agent) applies the suggested lines and re-runs.
|
|
811
|
+
// Disk is the recovery source per #4067's posture; the values below are
|
|
812
|
+
// computed from the SAME `scanOutstanding` counts the plans_outstanding
|
|
813
|
+
// guard uses, so the two declines can never disagree about a phase.
|
|
814
|
+
const positionToken = positionPhaseRef.value;
|
|
815
|
+
const diskFacts = positionToken !== null
|
|
816
|
+
? unsummarizedPlansForPositionPhase(cwd, positionToken)
|
|
817
|
+
: null;
|
|
818
|
+
if (diskFacts === null) {
|
|
819
|
+
// No resolvable phase (no Phase: line, no current_phase frontmatter, or
|
|
820
|
+
// no matching phase directory / incomplete scan): keep today's shape
|
|
821
|
+
// error, plus the reason so callers can tell this refusal from the
|
|
822
|
+
// ambiguous_* ones without string-matching the sentence.
|
|
823
|
+
output({ error: advancePlanShapeError(), reason: 'plan_position_unreadable' }, raw, undefined);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const planCount = diskFacts.planCount;
|
|
827
|
+
const summarized = diskFacts.summaryCount;
|
|
828
|
+
// A summarized count below the plan count means the next plan to execute
|
|
829
|
+
// is summarized+1; an equal count means the phase is done on disk and the
|
|
830
|
+
// position line should say so (current = total; the next advance-plan run
|
|
831
|
+
// takes the #4067-guarded phase-complete branch from it). Zero plan files
|
|
832
|
+
// means disk has no opinion — suggest nothing rather than `1 of 0`.
|
|
833
|
+
const payload = {
|
|
834
|
+
error: advancePlanShapeError(),
|
|
835
|
+
reason: 'plan_position_unreadable',
|
|
836
|
+
phase_dir: diskFacts.dir,
|
|
837
|
+
disk: { plan_count: planCount, summarized_count: summarized },
|
|
838
|
+
};
|
|
839
|
+
if (planCount > 0) {
|
|
840
|
+
const current = summarized < planCount ? summarized + 1 : planCount;
|
|
841
|
+
payload['suggested'] = {
|
|
842
|
+
current_plan: current,
|
|
843
|
+
total_plans: planCount,
|
|
844
|
+
lines: [`Current Plan: ${current}`, `Total Plans in Phase: ${planCount}`],
|
|
845
|
+
};
|
|
846
|
+
payload['error'] =
|
|
847
|
+
`${advancePlanShapeError()} Disk for phase ${diskFacts.dir}: ${summarized} of ${planCount} plan(s) summarized. ` +
|
|
848
|
+
`Re-insert a labeled plan position at the top of ## Current Position (e.g. Current Plan: ${current} with Total Plans in Phase: ${planCount}), then re-run.`;
|
|
849
|
+
}
|
|
850
|
+
output(payload, raw, undefined);
|
|
466
851
|
return;
|
|
467
852
|
}
|
|
468
853
|
// ADR-3408 §8.4 (D4): reconcile `advancePlanCore`'s own success list against
|
|
@@ -471,13 +856,25 @@ function cmdStateAdvancePlan(cwd, raw) {
|
|
|
471
856
|
// Generalizes fix(#3351) (closes #3351's direction) and folds in any field
|
|
472
857
|
// preservation restored that this transform never touched (#3345's
|
|
473
858
|
// direction).
|
|
474
|
-
const updated = reconcileReportedFields(statePath,
|
|
859
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
475
860
|
if (resultData['advanced'] === false) {
|
|
476
861
|
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'false');
|
|
477
862
|
}
|
|
478
863
|
else {
|
|
479
864
|
output({ ...resultData, updated, milestone_conflict: milestoneConflict }, raw, 'true');
|
|
480
865
|
}
|
|
866
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
|
|
867
|
+
// state.json `updated_at` must always mean something on disk actually
|
|
868
|
+
// moved, in EITHER branch above — so gate on `wrote`
|
|
869
|
+
// (readModifyWriteStateMd's own return value) rather than assuming both
|
|
870
|
+
// branches are unconditional mutations. They are not: re-running
|
|
871
|
+
// advance-plan on a phase already parked in its post-advance state (e.g.
|
|
872
|
+
// two same-day calls once a phase is "ready for verification") reproduces
|
|
873
|
+
// byte-identical content, the #948 no-op guard skips the write, and
|
|
874
|
+
// `resultData`/`updated` still populate normally from the transform's OWN
|
|
875
|
+
// (unwritten) output — so those are not safe publish signals here either.
|
|
876
|
+
if (wrote)
|
|
877
|
+
publishStateContract(cwd);
|
|
481
878
|
}
|
|
482
879
|
function cmdStateRecordMetric(cwd, options, raw) {
|
|
483
880
|
const statePath = planningPaths(cwd).state;
|
|
@@ -655,7 +1052,7 @@ function computeUpdateProgressPreview(statePath, cwd) {
|
|
|
655
1052
|
const existingFm = extractFrontmatter(preContent, statePath);
|
|
656
1053
|
const preBody = stripFrontmatter(preContent);
|
|
657
1054
|
const storedMilestone = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
658
|
-
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
1055
|
+
const builtFm = buildStateFrontmatter(preBody, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
659
1056
|
const progress = builtFm['progress'];
|
|
660
1057
|
const percent = progress && typeof progress['percent'] === 'number' ? progress['percent'] : null;
|
|
661
1058
|
const completedPlans = progress && typeof progress['completed_plans'] === 'number' ? progress['completed_plans'] : null;
|
|
@@ -696,7 +1093,39 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
696
1093
|
// excluded sentinels, unlike the owner). The owner already handles an
|
|
697
1094
|
// absent phasesDir as a real empty, so the fs.existsSync guard folds
|
|
698
1095
|
// into it.
|
|
699
|
-
|
|
1096
|
+
//
|
|
1097
|
+
// #2761 (round-11 BLOCKER, single-derivation hygiene): `phaseIdConvention`
|
|
1098
|
+
// threaded explicitly (resolved ambiently off `cwd` — this call site has
|
|
1099
|
+
// no `ws` of its own, same contract `resolvePhaseIdConvention` uses
|
|
1100
|
+
// elsewhere in this file, e.g. the `phaseConvention` ONCE-and-THREAD
|
|
1101
|
+
// pattern at ~:2267/:2300) rather than left `undefined`.
|
|
1102
|
+
//
|
|
1103
|
+
// This does NOT change `phaseScope` — `scope` (roadmap-parser.cts
|
|
1104
|
+
// `getMilestonePhaseFilter`) is assigned at :1979/:2030, both BEFORE
|
|
1105
|
+
// `headingConvention` resolves at ~:2048, so the #3217 withhold gate a
|
|
1106
|
+
// few lines below is convention-independent either way (verified
|
|
1107
|
+
// empirically: forcing `phaseIdConvention: null` here left every
|
|
1108
|
+
// `state update-progress` assertion in
|
|
1109
|
+
// tests/adr-612-bracket-phase-counting.test.cjs's round-11 BLOCKER block
|
|
1110
|
+
// unchanged). What DOES depend on convention is `phaseDirs`/`totalPlans`
|
|
1111
|
+
// — the enumerated `.value` these two lines feed into the #3233
|
|
1112
|
+
// zero-plans no-op check just below. The actual `percent` this command
|
|
1113
|
+
// reports/writes comes from a separate, already-correctly-threaded scan
|
|
1114
|
+
// (`computeUpdateProgressPreview` -> `buildStateFrontmatter`, which
|
|
1115
|
+
// resolves its own `phaseConvention` at :2267). Threading here removes a
|
|
1116
|
+
// second, silent, lazily-resolved answer for the SAME question that scan
|
|
1117
|
+
// already answers explicitly — the single-derivation discipline this
|
|
1118
|
+
// file's own :2300 comment states as a rule — rather than fixing an
|
|
1119
|
+
// observed defect. #2761 round-12: the #3233 gate IS the one place this
|
|
1120
|
+
// is observable, so it — not the reported percent — is what
|
|
1121
|
+
// tests/adr-612-bracket-phase-counting.test.cjs's round-12 addition to
|
|
1122
|
+
// the round-11 BLOCKER block pins: a bracket milestone with no plans on
|
|
1123
|
+
// disk versus a decoy directory outside the milestone window that must
|
|
1124
|
+
// not be swept in by a pass-all degrade.
|
|
1125
|
+
const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
1126
|
+
cwd,
|
|
1127
|
+
phaseIdConvention: cwd ? resolvePhaseIdConvention(cwd) : null,
|
|
1128
|
+
});
|
|
700
1129
|
phaseScope = scope;
|
|
701
1130
|
for (const dir of phaseDirs) {
|
|
702
1131
|
const { planCount } = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
@@ -713,10 +1142,10 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
713
1142
|
// never read, and STATE.md's Progress field goes stale with no
|
|
714
1143
|
// user-visible signal beyond it. Mirrors the established
|
|
715
1144
|
// `[gsd-tools] WARNING:` stderr convention this file already uses
|
|
716
|
-
// (stateReplaceFieldWithFallback above) for a comparable silent no-op
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1145
|
+
// (stateReplaceFieldWithFallback above) for a comparable silent no-op —
|
|
1146
|
+
// now routed through the shared `declineNoOp` helper (#3957) so the
|
|
1147
|
+
// pairing is structural rather than hand-written per arm.
|
|
1148
|
+
declineNoOp(raw, 'updated', `phase scope is ${phaseScope}, not complete`, `state update-progress skipped — phase scope is ${phaseScope}, not complete. STATE.md's Progress field was left unchanged.`);
|
|
720
1149
|
return;
|
|
721
1150
|
}
|
|
722
1151
|
// #3233: zero plans in the current-milestone phases means there is nothing to
|
|
@@ -728,9 +1157,7 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
728
1157
|
// ("nothing to measure" ≠ "0% done"). The legitimate 0% case (plans exist,
|
|
729
1158
|
// none summarized → clampPercent(0, N>0) = 0) is unaffected: totalPlans > 0.
|
|
730
1159
|
if (totalPlans === 0) {
|
|
731
|
-
|
|
732
|
-
`STATE.md's Progress field was left unchanged (milestone archived?).\n`);
|
|
733
|
-
output({ updated: false, reason: 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)' }, raw, 'false');
|
|
1160
|
+
declineNoOp(raw, 'updated', 'no plans found in current-milestone phases — STATE.md left unchanged (milestone archived?)', `state update-progress skipped — no plans found in current-milestone phases (0 plans). STATE.md's Progress field was left unchanged (milestone archived?).`);
|
|
734
1161
|
return;
|
|
735
1162
|
}
|
|
736
1163
|
// #3583: percent AND the completed/total counts reported alongside it both
|
|
@@ -741,48 +1168,30 @@ function cmdStateUpdateProgress(cwd, raw) {
|
|
|
741
1168
|
// disagrees with its own completed/total.
|
|
742
1169
|
const preview = computeUpdateProgressPreview(statePath, cwd);
|
|
743
1170
|
if (preview.withheld) {
|
|
744
|
-
|
|
745
|
-
output({ updated: false, reason: preview.reason }, raw, 'false');
|
|
1171
|
+
declineNoOp(raw, 'updated', preview.reason, `state update-progress skipped — ${preview.reason}`);
|
|
746
1172
|
return;
|
|
747
1173
|
}
|
|
748
1174
|
const { percent, completedPlans: fmCompletedPlans, totalPlans: fmTotalPlans } = preview;
|
|
749
|
-
const
|
|
750
|
-
const filled = Math.round(percent / 100 * barWidth);
|
|
751
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
752
|
-
const progressStr = `[${bar}] ${percent}%`;
|
|
1175
|
+
const progressStr = formatProgressMachineSegment(percent);
|
|
753
1176
|
let updated = false;
|
|
754
1177
|
readModifyWriteStateMd(statePath, (content) => {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// eat its newline, mangling the nested block), while the body Progress: line
|
|
758
|
-
// — which frontmatter `percent` is re-derived from on every write — stays
|
|
759
|
-
// stale and silently reverts the update.
|
|
760
|
-
const body = stripFrontmatter(content);
|
|
761
|
-
const fmPrefix = content.slice(0, content.length - body.length);
|
|
762
|
-
// Swap only the machine segment ("[bar] NN%" or bare "NN%"), preserving any
|
|
763
|
-
// descriptive suffix an agent authored, e.g. "(2/4 plans done; blocked on…)".
|
|
764
|
-
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
|
|
765
|
-
const replaceValue = (value) => machineSegment.test(value)
|
|
766
|
-
? value.replace(machineSegment, progressStr)
|
|
767
|
-
: progressStr;
|
|
768
|
-
// Try **Progress:** bold format first, then plain Progress: format.
|
|
769
|
-
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
|
|
770
|
-
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
|
|
771
|
-
const pattern = boldProgressPattern.test(body)
|
|
772
|
-
? boldProgressPattern
|
|
773
|
-
: plainProgressPattern.test(body)
|
|
774
|
-
? plainProgressPattern
|
|
775
|
-
: null;
|
|
776
|
-
if (!pattern)
|
|
1178
|
+
const result = stateReplaceProgressPercent(content, percent);
|
|
1179
|
+
if (result === null)
|
|
777
1180
|
return content;
|
|
778
1181
|
updated = true;
|
|
779
|
-
return
|
|
1182
|
+
return result;
|
|
780
1183
|
}, cwd);
|
|
781
1184
|
if (updated) {
|
|
782
1185
|
output({ updated: true, percent, completed: fmCompletedPlans, total: fmTotalPlans, bar: progressStr }, raw, progressStr);
|
|
783
1186
|
}
|
|
784
1187
|
else {
|
|
785
|
-
|
|
1188
|
+
// #3957: the frontmatter progress data was already confirmed present a
|
|
1189
|
+
// few lines above (computeUpdateProgressPreview didn't withhold) — what's
|
|
1190
|
+
// actually missing here is the BODY `Progress:`/`**Progress:**` line
|
|
1191
|
+
// itself. The prior 'Progress field not found in STATE.md' reason named
|
|
1192
|
+
// the wrong layer and silently discarded percent/completed/total, which
|
|
1193
|
+
// the sibling success arm above reports from the same preview.
|
|
1194
|
+
declineNoOp(raw, 'updated', 'no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected)', 'state update-progress skipped — no Progress: line found in STATE.md body to update (frontmatter progress data is unaffected).', { percent, completed: fmCompletedPlans, total: fmTotalPlans });
|
|
786
1195
|
}
|
|
787
1196
|
}
|
|
788
1197
|
function cmdStateAddDecision(cwd, options, raw) {
|
|
@@ -1088,7 +1497,15 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1088
1497
|
output({ error: 'text required' }, raw, undefined);
|
|
1089
1498
|
return;
|
|
1090
1499
|
}
|
|
1091
|
-
|
|
1500
|
+
// #3957: track section-found and bullet-matched SEPARATELY. Previously
|
|
1501
|
+
// `resolved` was set unconditionally as soon as the heading was located —
|
|
1502
|
+
// before checking whether any bullet line actually matched `text` — so a
|
|
1503
|
+
// call naming a non-existent blocker reported `resolved: true` (a false
|
|
1504
|
+
// success). Only a real bullet match makes `resolved` true and the
|
|
1505
|
+
// rewrite happen; otherwise the transform returns `content` unchanged
|
|
1506
|
+
// (this repo's established no-op-return idiom).
|
|
1507
|
+
let sectionFound = false;
|
|
1508
|
+
let matched = false;
|
|
1092
1509
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1093
1510
|
// ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
|
|
1094
1511
|
// Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
|
|
@@ -1096,6 +1513,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1096
1513
|
const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
|
|
1097
1514
|
if (i === -1)
|
|
1098
1515
|
return content;
|
|
1516
|
+
sectionFound = true;
|
|
1099
1517
|
const h = hs[i];
|
|
1100
1518
|
const ls = content.split('\n');
|
|
1101
1519
|
const hl = ls[h.line - 1];
|
|
@@ -1112,21 +1530,33 @@ function cmdStateResolveBlocker(cwd, text, raw) {
|
|
|
1112
1530
|
const filtered = lines.filter(line => {
|
|
1113
1531
|
if (!line.startsWith('- '))
|
|
1114
1532
|
return true;
|
|
1115
|
-
|
|
1533
|
+
// Case-insensitive substring match — unchanged from before the fix;
|
|
1534
|
+
// only whether a match occurred is now tracked accurately.
|
|
1535
|
+
const isMatch = line.toLowerCase().includes(text.toLowerCase());
|
|
1536
|
+
if (isMatch)
|
|
1537
|
+
matched = true;
|
|
1538
|
+
return !isMatch;
|
|
1116
1539
|
});
|
|
1540
|
+
if (!matched)
|
|
1541
|
+
return content;
|
|
1117
1542
|
let newBody = filtered.join('\n');
|
|
1118
1543
|
// If section is now empty, add placeholder
|
|
1119
1544
|
if (!newBody.trim() || !newBody.includes('- ')) {
|
|
1120
1545
|
newBody = 'None\n';
|
|
1121
1546
|
}
|
|
1122
|
-
resolved = true;
|
|
1123
1547
|
return content.slice(0, bs) + newBody + content.slice(se);
|
|
1124
1548
|
}, cwd);
|
|
1125
|
-
if (
|
|
1549
|
+
if (matched) {
|
|
1126
1550
|
output({ resolved: true, blocker: text }, raw, 'true');
|
|
1127
1551
|
}
|
|
1552
|
+
else if (!sectionFound) {
|
|
1553
|
+
declineNoOp(raw, 'resolved', 'no Blockers/Concerns section found in STATE.md', 'state resolve-blocker skipped — no Blockers/Concerns section found in STATE.md.');
|
|
1554
|
+
}
|
|
1128
1555
|
else {
|
|
1129
|
-
|
|
1556
|
+
// `formatDiagnosticToken` only guards the STDERR disclosure — the JSON
|
|
1557
|
+
// `reason` field can embed `text` raw since output()'s own
|
|
1558
|
+
// JSON.stringify serialization already escapes it correctly.
|
|
1559
|
+
declineNoOp(raw, 'resolved', `no blocker matching ${text} found in the Blockers section`, `state resolve-blocker skipped — no blocker matching ${formatDiagnosticToken(text)} found in the Blockers section.`);
|
|
1130
1560
|
}
|
|
1131
1561
|
}
|
|
1132
1562
|
function cmdStateRecordSession(cwd, options, raw) {
|
|
@@ -1138,8 +1568,10 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1138
1568
|
const now = clock_cjs_1.realClock.nowIso();
|
|
1139
1569
|
const updated = [];
|
|
1140
1570
|
let sessionCreated = false;
|
|
1141
|
-
let preSyncContent = '';
|
|
1142
1571
|
const divergedFields = [];
|
|
1572
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
1573
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
1574
|
+
const preWriteState = {};
|
|
1143
1575
|
readModifyWriteStateMd(statePath, (content) => {
|
|
1144
1576
|
// Update Last session / Last Date
|
|
1145
1577
|
let result = (0, state_document_cjs_1.stateReplaceField)(content, 'Last session', now);
|
|
@@ -1353,22 +1785,35 @@ function cmdStateRecordSession(cwd, options, raw) {
|
|
|
1353
1785
|
updated.push('Resume File');
|
|
1354
1786
|
}
|
|
1355
1787
|
}
|
|
1356
|
-
preSyncContent = content;
|
|
1357
1788
|
return content;
|
|
1358
|
-
}, cwd, { divergedFields });
|
|
1789
|
+
}, cwd, { divergedFields, preWriteState });
|
|
1359
1790
|
// ADR-3408 §8.4 (D4): reconcile this command's own success list against the
|
|
1360
1791
|
// bytes actually persisted (fix(#3351) generalized) and fold in any field
|
|
1361
1792
|
// preservation restored that this transform never touched (#3345's
|
|
1362
1793
|
// direction).
|
|
1363
|
-
const reconciledUpdated = reconcileReportedFields(statePath,
|
|
1794
|
+
const reconciledUpdated = reconcileReportedFields(statePath, preWriteState, updated, divergedFields);
|
|
1364
1795
|
if (reconciledUpdated.length > 0) {
|
|
1365
1796
|
const result = { recorded: true, updated: reconciledUpdated };
|
|
1366
1797
|
if (sessionCreated)
|
|
1367
1798
|
result['created'] = true;
|
|
1368
1799
|
output(result, raw, 'true');
|
|
1369
1800
|
}
|
|
1801
|
+
else if (updated.length === 0) {
|
|
1802
|
+
// Nothing was ever attempted — no --stopped-at/--resume-file supplied
|
|
1803
|
+
// and no existing Last session/Last Date/Stopped At/Resume File labels
|
|
1804
|
+
// to touch.
|
|
1805
|
+
declineNoOp(raw, 'recorded', 'no session fields found in STATE.md to update', 'state record-session skipped — no session fields found in STATE.md to update.');
|
|
1806
|
+
}
|
|
1370
1807
|
else {
|
|
1371
|
-
|
|
1808
|
+
// #3957: `updated` (pre-reconciliation) was non-empty — a rewrite
|
|
1809
|
+
// matched a session field and reported it as changed — but
|
|
1810
|
+
// `reconcileReportedFields` found the persisted bytes byte-identical to
|
|
1811
|
+
// what was already on disk (the matched field's supplied value equals
|
|
1812
|
+
// its already-recorded value), so nothing actually changed. Distinct
|
|
1813
|
+
// from the "nothing was ever attempted" case above: the prior single
|
|
1814
|
+
// reason collapsed both into 'No session fields found in STATE.md',
|
|
1815
|
+
// which was simply wrong for this case.
|
|
1816
|
+
declineNoOp(raw, 'recorded', 'the matched session field(s) already held the reported value — no bytes changed', 'state record-session skipped — the matched session field(s) already held the reported value; no bytes changed.');
|
|
1372
1817
|
}
|
|
1373
1818
|
}
|
|
1374
1819
|
/**
|
|
@@ -1670,7 +2115,56 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1670
2115
|
// ROADMAP phase token against an on-disk phase directory — moved to the
|
|
1671
2116
|
// phase-id owner module in #2562 so every consumer derives BOTH sides of a
|
|
1672
2117
|
// phase comparison from the same function (see phase-id.cts). Imported at the
|
|
1673
|
-
// top of this file; call sites below are unchanged.
|
|
2118
|
+
// top of this file; call sites below are unchanged. #612 threads the optional
|
|
2119
|
+
// `convention` through that owner's `phaseKeyFromDir` (see phase-id.cts) rather
|
|
2120
|
+
// than re-deriving a bracket-aware key here.
|
|
2121
|
+
/**
|
|
2122
|
+
* #612: is the asserted milestone bounded to a heading in this ROADMAP?
|
|
2123
|
+
*
|
|
2124
|
+
* The legacy rule matches STATE's milestone STRING (`v2.0`) inside a heading.
|
|
2125
|
+
* The ADR-canonical bracket milestone heading is `## [GSD.02] Foundation` — a
|
|
2126
|
+
* name, no version — so that rule finds nothing, the milestone reads as
|
|
2127
|
+
* unbounded, and total_phases falls back to the on-disk directory count. Under
|
|
2128
|
+
* the bracket convention the milestone integer in the bracket is matched against
|
|
2129
|
+
* the `vN` of the milestone string instead (READING-B parity). Gated, and only
|
|
2130
|
+
* consulted after the legacy rule has already failed, so no non-bracket repo
|
|
2131
|
+
* changes answer.
|
|
2132
|
+
*/
|
|
2133
|
+
function isMilestoneBounded(roadmapRaw, milestone, convention) {
|
|
2134
|
+
// #3184: preserve roadmap-parser's canonical legacy answer and compose the
|
|
2135
|
+
// gated bracket extension on top of it. Re-deriving the version-heading
|
|
2136
|
+
// grammar here would restore the boundary drift that #3184 removed.
|
|
2137
|
+
if (isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim()))
|
|
2138
|
+
return true;
|
|
2139
|
+
if (convention !== 'bracket')
|
|
2140
|
+
return false;
|
|
2141
|
+
const vMatch = String(milestone).trim().match(/^v(\d+)/i);
|
|
2142
|
+
const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN;
|
|
2143
|
+
if (!Number.isSafeInteger(milestoneInt))
|
|
2144
|
+
return false;
|
|
2145
|
+
// Canonical spelling only — see the note in roadmap-parser's scoping branch.
|
|
2146
|
+
// Accepting `0*N` here bounded a milestone whose phases were invisible, which
|
|
2147
|
+
// un-suppressed a progress percent computed off an unscoped disk count.
|
|
2148
|
+
// #2761 M3: that padding rule and the grammar both come from the owner's
|
|
2149
|
+
// `bracketMilestoneIntroSrcFor`. This line and roadmap-parser's selector were
|
|
2150
|
+
// character-identical re-typings of one pattern, so "canonical spelling only"
|
|
2151
|
+
// was a convention two files had to keep agreeing on by hand — and the drift
|
|
2152
|
+
// guard could not see either copy.
|
|
2153
|
+
// #612 round-4 (Major 1, F12): fence-aware via tokenizeHeadings, not a raw
|
|
2154
|
+
// `.test(roadmapRaw)` — a FENCED `[GSD.02]` example heading (the ONLY one
|
|
2155
|
+
// in the document, with no real section for the asserted milestone at
|
|
2156
|
+
// all) previously bounded a milestone that isn't actually in the roadmap,
|
|
2157
|
+
// un-suppressing a percent computed off the wrong (prior-milestone-plus-
|
|
2158
|
+
// whole-disk) phase set. tokenizeHeadings never produces a token for a
|
|
2159
|
+
// fenced line, so a fenced-only example can no longer satisfy this test.
|
|
2160
|
+
const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i');
|
|
2161
|
+
// #612 round-5 (Minor 1): skip ≤3-space-indented tokens — `h.offset` is
|
|
2162
|
+
// tokenizeHeadings' LINE-START offset, not the `#` character, so an
|
|
2163
|
+
// indented heading here would bound a milestone the line-start-anchored
|
|
2164
|
+
// raw predecessor never matched. Restores raw parity; see roadmap-parser's
|
|
2165
|
+
// matching selector-reconstruction comment for the full rationale.
|
|
2166
|
+
return (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapRaw).some((h) => h.level <= 3 && roadmapRaw[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text));
|
|
2167
|
+
}
|
|
1674
2168
|
/**
|
|
1675
2169
|
* Extract the set of retired/folded phase keys from a ROADMAP milestone scope
|
|
1676
2170
|
* (#1514). A retired phase is struck through with GFM strikethrough,
|
|
@@ -1692,16 +2186,31 @@ function cmdStateSnapshot(cwd, raw) {
|
|
|
1692
2186
|
* decimal, and project-code IDs are detected alike. Returns canonical keys
|
|
1693
2187
|
* (see phaseKeyFromToken).
|
|
1694
2188
|
*/
|
|
1695
|
-
function extractRetiredPhaseNumbers(scope) {
|
|
2189
|
+
function extractRetiredPhaseNumbers(scope, convention) {
|
|
1696
2190
|
const retired = new Set();
|
|
1697
2191
|
const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
|
|
1698
|
-
|
|
2192
|
+
// #612: the retirement filter has to widen with the counter it protects. The
|
|
2193
|
+
// canonical #1514 gesture strikes the checklist BULLET and leaves the detail
|
|
2194
|
+
// heading intact, so a bracket-form retirement went undetected and the phase
|
|
2195
|
+
// stayed in the denominator forever — a shipped bracket milestone could never
|
|
2196
|
+
// reach 100%. Same selection rule as the counter: a non-bracket repo compiles
|
|
2197
|
+
// the bare `Phase\s+` this line spelled before.
|
|
2198
|
+
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
|
|
2199
|
+
const phaseRefRe = new RegExp(`^[\\s*_]*${introSrc}([\\w][\\w.-]*)`, 'i');
|
|
2200
|
+
// #612 round-5 (Major 1): fence-aware on the BRACKET path only — a fenced
|
|
2201
|
+
// AUTHORING EXAMPLE of the #1514 retirement gesture, spelled in bracket
|
|
2202
|
+
// form, must not retire a real phase. Reuses markdown-sectionizer's
|
|
2203
|
+
// single-owner stripFencedCode rather than a second fence parser. Legacy
|
|
2204
|
+
// stays the raw `scope`, byte-identical — its own fenced-example hazard is
|
|
2205
|
+
// pre-existing and out of scope.
|
|
2206
|
+
const scanScope = convention === 'bracket' ? (0, markdown_sectionizer_cjs_1.stripFencedCode)(scope).text : scope;
|
|
2207
|
+
for (const line of scanScope.split(/\r?\n/)) {
|
|
1699
2208
|
if (!isChecklistOrHeading.test(line))
|
|
1700
2209
|
continue;
|
|
1701
2210
|
const strikeSpan = /~~([^~]*?)~~/g;
|
|
1702
2211
|
let s;
|
|
1703
2212
|
while ((s = strikeSpan.exec(line)) !== null) {
|
|
1704
|
-
const phaseRef =
|
|
2213
|
+
const phaseRef = phaseRefRe.exec(s[1]);
|
|
1705
2214
|
// Require a digit so struck prose like ~~Phase Overview~~ is ignored.
|
|
1706
2215
|
if (phaseRef && /\d/.test(phaseRef[1]))
|
|
1707
2216
|
retired.add(phaseKeyFromToken(phaseRef[1]));
|
|
@@ -1709,12 +2218,110 @@ function extractRetiredPhaseNumbers(scope) {
|
|
|
1709
2218
|
}
|
|
1710
2219
|
return retired;
|
|
1711
2220
|
}
|
|
2221
|
+
/**
|
|
2222
|
+
* #612 (round-4 fix): the single shared implementation for the phase-heading
|
|
2223
|
+
* counter `buildStateFrontmatter` (read path) and `cmdStateSync` (write
|
|
2224
|
+
* path) each built inline as an independent copy. The comment at each call
|
|
2225
|
+
* site already claimed "the two counters must see the same phases or
|
|
2226
|
+
* `state json` and `state sync` report different totals for one repo
|
|
2227
|
+
* (#3242 Bug B)" — this makes that invariant STRUCTURAL (one implementation,
|
|
2228
|
+
* two call sites) instead of two copies a future edit could silently
|
|
2229
|
+
* diverge.
|
|
2230
|
+
*
|
|
2231
|
+
* Two DELIBERATELY DIFFERENT counting strategies, selected by `convention`:
|
|
2232
|
+
*
|
|
2233
|
+
* - BRACKET: counts via `tokenizeHeadings(scope)` at levels 2-4 (mirroring
|
|
2234
|
+
* `getMilestonePhaseFilter`'s own level bound, `roadmap-parser.cts:1090`),
|
|
2235
|
+
* testing each heading's (hash-stripped, fence-STRIPPED-by-construction)
|
|
2236
|
+
* text against the phase-heading-intro grammar directly. Fence-aware by
|
|
2237
|
+
* construction — `tokenizeHeadings` never produces a token for a fenced
|
|
2238
|
+
* line — closing round-4's Major 1: a fenced EXAMPLE phase heading in the
|
|
2239
|
+
* preamble (`` ### [GSD.02] 05: Example phase `` inside a
|
|
2240
|
+
* ` ```markdown ` block) previously inflated this count via the raw regex
|
|
2241
|
+
* below, which ran over the whole scope STRING with no fence awareness at
|
|
2242
|
+
* all (F9, F10 — `total_phases` read 3 where the milestone has 2 real
|
|
2243
|
+
* phases). The producer (`extractCurrentMilestone`'s returned scope
|
|
2244
|
+
* string) is deliberately NOT changed — every other consumer of that
|
|
2245
|
+
* string needs its full content fidelity, and the legacy path's identity
|
|
2246
|
+
* forbids touching the string all consumers share; this fixes the
|
|
2247
|
+
* COUNTING, not the scope.
|
|
2248
|
+
*
|
|
2249
|
+
* - LEGACY (any non-bracket convention, including unresolved/null): retain
|
|
2250
|
+
* the existing raw `content.exec()` counting strategy. On the read path,
|
|
2251
|
+
* route sentinel exclusion through #3185's canonical predicate; the sync
|
|
2252
|
+
* path intentionally retains its pre-existing absence of that exclusion.
|
|
2253
|
+
*
|
|
2254
|
+
* `applyConventionTokenSentinelRules` makes the remaining convention-specific
|
|
2255
|
+
* asymmetry explicit. Both read and sync exclude bare bracket token 999; only
|
|
2256
|
+
* the read path excludes canonical legacy sentinels. Both bracket paths also
|
|
2257
|
+
* retain the bracket-id and bare-0 rules. Sharing the implementation therefore
|
|
2258
|
+
* cannot silently move either convention's total.
|
|
2259
|
+
*/
|
|
2260
|
+
function countRoadmapPhaseHeadings(scope, convention, retiredPhaseNums, applyConventionTokenSentinelRules) {
|
|
2261
|
+
let count = 0;
|
|
2262
|
+
if (convention === 'bracket') {
|
|
2263
|
+
const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true);
|
|
2264
|
+
const phaseHeadingPattern = new RegExp(`^${introSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i');
|
|
2265
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(scope)) {
|
|
2266
|
+
if (h.level < 2 || h.level > 4)
|
|
2267
|
+
continue;
|
|
2268
|
+
const m = phaseHeadingPattern.exec(h.text);
|
|
2269
|
+
if (!m)
|
|
2270
|
+
continue;
|
|
2271
|
+
const bracketId = m[1];
|
|
2272
|
+
const token = m[2];
|
|
2273
|
+
// Only count tokens that contain at least one digit — excludes
|
|
2274
|
+
// pure-word section headings (Overview, Details) while keeping
|
|
2275
|
+
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
2276
|
+
if (!/\d/.test(token))
|
|
2277
|
+
continue;
|
|
2278
|
+
// #612 READING-B: a bracket heading carries its sentinel in the
|
|
2279
|
+
// bracket, so `### [GSD.999] 01:` is an icebox item even though its
|
|
2280
|
+
// token is `01`.
|
|
2281
|
+
if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket'))
|
|
2282
|
+
continue;
|
|
2283
|
+
// #612: under bracket the token rule composes with the bracket-id
|
|
2284
|
+
// check as the engine's {0, 999} sentinel set.
|
|
2285
|
+
if (bracketId && /^0\b/.test(token))
|
|
2286
|
+
continue;
|
|
2287
|
+
if (applyConventionTokenSentinelRules && /^999\b/.test(token))
|
|
2288
|
+
continue;
|
|
2289
|
+
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
2290
|
+
// exclude them from the denominator (they can never be completed).
|
|
2291
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2292
|
+
continue;
|
|
2293
|
+
count++;
|
|
2294
|
+
}
|
|
2295
|
+
return count;
|
|
2296
|
+
}
|
|
2297
|
+
// LEGACY stays on the pre-round-4 raw exec loop. #3185 owns the read-path
|
|
2298
|
+
// sentinel predicate; sync deliberately preserves its prior behavior.
|
|
2299
|
+
const phaseHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
|
|
2300
|
+
let m;
|
|
2301
|
+
while ((m = phaseHeadingPattern.exec(scope)) !== null) {
|
|
2302
|
+
const token = m[1];
|
|
2303
|
+
if (!/\d/.test(token))
|
|
2304
|
+
continue;
|
|
2305
|
+
if (applyConventionTokenSentinelRules && isSentinelPhaseId(token))
|
|
2306
|
+
continue;
|
|
2307
|
+
if (retiredPhaseNums.has(phaseKeyFromToken(token)))
|
|
2308
|
+
continue;
|
|
2309
|
+
count++;
|
|
2310
|
+
}
|
|
2311
|
+
return count;
|
|
2312
|
+
}
|
|
1712
2313
|
/**
|
|
1713
2314
|
* Extract machine-readable fields from STATE.md markdown body and build
|
|
1714
2315
|
* a YAML frontmatter object. Allows hooks and scripts to read state
|
|
1715
2316
|
* reliably via `state json` instead of fragile regex parsing.
|
|
1716
2317
|
*/
|
|
1717
|
-
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases
|
|
2318
|
+
function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPhases,
|
|
2319
|
+
// #4094: the stored siblings of storedTotalPhases, threaded from each call
|
|
2320
|
+
// site exactly the same way — see readStoredProgressCounter below. Under the
|
|
2321
|
+
// #3354/#3573 withhold condition the disk scan returns null for all four
|
|
2322
|
+
// counters, and these stored values are what the progress block falls back
|
|
2323
|
+
// to (else the keys are omitted).
|
|
2324
|
+
storedCompletedPhases, storedTotalPlans, storedCompletedPlans) {
|
|
1718
2325
|
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
|
|
1719
2326
|
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
|
|
1720
2327
|
// below). Phase canonically lives in ## Current Position (templates/state.md);
|
|
@@ -1804,6 +2411,10 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1804
2411
|
// from the pre-existing frontmatter fields parsed above, a path this phase
|
|
1805
2412
|
// does not touch and which predates listMilestonePhaseDirs entirely.
|
|
1806
2413
|
let diskScope = SCOPE.COMPLETE;
|
|
2414
|
+
// #612: resolved ONCE per call, federated workstream -> root, and shared by
|
|
2415
|
+
// the heading counter, the retirement filter and the retired-directory skip so
|
|
2416
|
+
// no two of them can split on different answers.
|
|
2417
|
+
const phaseConvention = cwd ? resolvePhaseIdConvention(cwd) : null;
|
|
1807
2418
|
if (cwd) {
|
|
1808
2419
|
try {
|
|
1809
2420
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -1824,7 +2435,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1824
2435
|
roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1825
2436
|
if (roadmapRaw !== null) {
|
|
1826
2437
|
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
1827
|
-
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
|
|
2438
|
+
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention);
|
|
1828
2439
|
}
|
|
1829
2440
|
}
|
|
1830
2441
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
@@ -1835,7 +2446,11 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1835
2446
|
// CURRENT (stored) milestone" — routed through the canonical owner
|
|
1836
2447
|
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
1837
2448
|
// (which also never excluded sentinels, unlike the owner).
|
|
1838
|
-
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2449
|
+
const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, {
|
|
2450
|
+
cwd,
|
|
2451
|
+
versionOverride: storedMilestone ?? null,
|
|
2452
|
+
phaseIdConvention: phaseConvention,
|
|
2453
|
+
});
|
|
1839
2454
|
// Bug #2445: when stale phase dirs from a prior milestone remain in
|
|
1840
2455
|
// .planning/phases/ alongside new dirs with the same phase number,
|
|
1841
2456
|
// de-duplicate by normalized phase number keeping exactly one dir
|
|
@@ -1847,7 +2462,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1847
2462
|
// artifact; drop it from the disk phase set so it counts toward
|
|
1848
2463
|
// neither the denominator nor the numerator (mirrors the heading
|
|
1849
2464
|
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
|
1850
|
-
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir)))
|
|
2465
|
+
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention)))
|
|
1851
2466
|
continue;
|
|
1852
2467
|
// #3185: dedup grouping routed through the canonical phaseKeyFromDir
|
|
1853
2468
|
// (src/phase-id.cts) instead of a local leading-digits regex that
|
|
@@ -1856,7 +2471,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1856
2471
|
// so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on
|
|
1857
2472
|
// multi-segment milestone dirs. Same key surface used two lines
|
|
1858
2473
|
// above for the retiredPhaseNums exclusion, so both filters agree.
|
|
1859
|
-
const key = phaseKeyFromDir(dir);
|
|
2474
|
+
const key = phaseKeyFromDir(dir, phaseConvention);
|
|
1860
2475
|
if (!seenPhaseNums.has(key)) {
|
|
1861
2476
|
seenPhaseNums.set(key, dir);
|
|
1862
2477
|
}
|
|
@@ -1900,31 +2515,15 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1900
2515
|
if (isPhaseComplete(phaseDir).value.complete)
|
|
1901
2516
|
diskCompletedPhases++;
|
|
1902
2517
|
}
|
|
1903
|
-
// Count phase headings from ROADMAP
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
let m;
|
|
1913
|
-
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
|
1914
|
-
// Only count tokens that contain at least one digit — excludes
|
|
1915
|
-
// pure-word section headings (Overview, Details) while keeping
|
|
1916
|
-
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
1917
|
-
// Also exclude sentinel phases (0 and 999.x backlog).
|
|
1918
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1919
|
-
if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1]))
|
|
1920
|
-
continue;
|
|
1921
|
-
// #1514: retired/folded phases are struck through in the ROADMAP;
|
|
1922
|
-
// exclude them from the denominator (they can never be completed).
|
|
1923
|
-
if (retiredPhaseNums.has(phaseKeyFromToken(m[1])))
|
|
1924
|
-
continue;
|
|
1925
|
-
roadmapPhaseCount++;
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
2518
|
+
// Count phase headings from ROADMAP — single source of truth for
|
|
2519
|
+
// total_phases (#549). #612 round-4: shared with cmdStateSync's
|
|
2520
|
+
// identical-purpose counter via countRoadmapPhaseHeadings (above
|
|
2521
|
+
// extractRetiredPhaseNumbers). The shared helper composes its
|
|
2522
|
+
// fence-aware bracket strategy with #3185's canonical legacy
|
|
2523
|
+
// sentinel predicate for this read-path call.
|
|
2524
|
+
const roadmapPhaseCount = roadmapScope !== null
|
|
2525
|
+
? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true)
|
|
2526
|
+
: 0;
|
|
1928
2527
|
cached = (() => {
|
|
1929
2528
|
// #1761 read-path: mirror the cmdStateSync guard (#1794). When the
|
|
1930
2529
|
// asserted milestone version can't be bounded to a versioned ROADMAP
|
|
@@ -1949,7 +2548,7 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1949
2548
|
// the prior inline regex had no boundary assertion after the
|
|
1950
2549
|
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
|
|
1951
2550
|
// defect, design row 17).
|
|
1952
|
-
milestoneBounded =
|
|
2551
|
+
milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention);
|
|
1953
2552
|
}
|
|
1954
2553
|
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
|
|
1955
2554
|
// at all — only Phase headings) from a MILESTONED-but-unbounded one
|
|
@@ -1961,10 +2560,19 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1961
2560
|
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
|
|
1962
2561
|
// version-token requirement); see hasMilestoneSectioning's own
|
|
1963
2562
|
// doc comment for why that distinction is load-bearing.
|
|
1964
|
-
|
|
1965
|
-
|
|
2563
|
+
// #3642: the flat test uses the >=1 sibling (hasAnyMilestoneSection),
|
|
2564
|
+
// not the >=2 predicate. >=2 under-answers the question this branch
|
|
2565
|
+
// asks: with EXACTLY ONE milestone section and an asserted milestone
|
|
2566
|
+
// absent from the ROADMAP, >=2 read "flat" and the whole-document
|
|
2567
|
+
// count — which IS that single section's phases — was written as the
|
|
2568
|
+
// asserted milestone's total, silently clobbering the stored value.
|
|
2569
|
+
// The >=2 threshold governs SIBLING conflation; asserted-vs-section
|
|
2570
|
+
// needs only one section to go wrong. Zero sections (genuinely flat)
|
|
2571
|
+
// keeps the whole-document count, per #2828.
|
|
2572
|
+
const roadmapHasAnyMilestoneSection = roadmapRaw !== null
|
|
2573
|
+
&& hasAnyMilestoneSection(roadmapRaw);
|
|
1966
2574
|
const safeToUseRoadmapCount = milestoneBounded
|
|
1967
|
-
|| (roadmapPhaseCount > 0 && !
|
|
2575
|
+
|| (roadmapPhaseCount > 0 && !roadmapHasAnyMilestoneSection);
|
|
1968
2576
|
// #3354: the milestoned-but-unbounded sibling of the #2828/#3204
|
|
1969
2577
|
// shapes. The whole-document roadmapPhaseCount is rightly rejected
|
|
1970
2578
|
// above (it would conflate sibling milestones, #1761), but the
|
|
@@ -1980,9 +2588,9 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1980
2588
|
// The degenerate un-sectioned zero-heading case keeps the
|
|
1981
2589
|
// phaseDirs.length fallback — with nothing declared anywhere else,
|
|
1982
2590
|
// the disk count is the only source and remains correct.
|
|
1983
|
-
const milestonedButUnbounded = !milestoneBounded &&
|
|
2591
|
+
const milestonedButUnbounded = !milestoneBounded && roadmapHasAnyMilestoneSection;
|
|
1984
2592
|
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
|
|
2593
|
+
process.stderr.write(`gsd: warning — milestone '${String(assertedMilestoneVersion ?? '').trim()}' is asserted in STATE.md but matches no ROADMAP heading, and the ROADMAP carries milestone section(s) — one (#3642) or several (#3354) — none matching it; the whole-document count would attribute a foreign section's phases to this milestone and the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3354/#3642/#4094)\n`);
|
|
1986
2594
|
}
|
|
1987
2595
|
// #3573: the roadmap-absent sibling of the #3354 shape. With ROADMAP.md
|
|
1988
2596
|
// absent/unreadable the #549 heading counter never ran (roadmapScope
|
|
@@ -1999,21 +2607,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
1999
2607
|
typeof storedMilestone === 'string' &&
|
|
2000
2608
|
storedMilestone.trim() !== '';
|
|
2001
2609
|
if (roadmapAbsentWithAssertedMilestone) {
|
|
2002
|
-
process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so progress
|
|
2610
|
+
process.stderr.write(`gsd: warning — milestone '${storedMilestone.trim()}' is asserted in STATE.md but ROADMAP.md is absent or unreadable, so the phase-heading total cannot be derived; the on-disk phase-directory count would understate the declared total, so the progress counters (total_phases, completed_phases, total_plans, completed_plans) are left at their stored values. (#3573) (#4094)\n`);
|
|
2003
2611
|
}
|
|
2612
|
+
// #4094: the withhold condition covers ALL FOUR progress counters,
|
|
2613
|
+
// not just total_phases. completed_phases / total_plans /
|
|
2614
|
+
// completed_plans are accumulated from the exact same phaseDirs
|
|
2615
|
+
// walk as total_phases (same loop, same scope, same filters), so
|
|
2616
|
+
// whenever that walk's scope is known-untrustworthy — the exact
|
|
2617
|
+
// condition #3354 established — they are equally untrustworthy.
|
|
2618
|
+
// Pre-#4094 only totalPhases was nulled here, so every resyncing
|
|
2619
|
+
// write silently clobbered the three stored siblings with the
|
|
2620
|
+
// under-scoped disk numbers.
|
|
2621
|
+
const diskCountsWithheld = milestonedButUnbounded || roadmapAbsentWithAssertedMilestone;
|
|
2004
2622
|
return {
|
|
2005
2623
|
// The two WITHHOLD shapes (#3354 milestoned-but-unbounded, #3573
|
|
2006
2624
|
// roadmap-absent-with-asserted-milestone) must be evaluated BEFORE
|
|
2007
2625
|
// safeToUseRoadmapCount — in the #3573 shape milestoneBounded is
|
|
2008
2626
|
// vacuously true (its gate requires roadmapRaw), so the safe-count
|
|
2009
2627
|
// arm would otherwise swallow the withhold.
|
|
2010
|
-
totalPhases:
|
|
2628
|
+
totalPhases: diskCountsWithheld
|
|
2011
2629
|
? null
|
|
2012
2630
|
: (safeToUseRoadmapCount ? Math.max(phaseDirs.length, roadmapPhaseCount) : phaseDirs.length),
|
|
2013
2631
|
milestoneBounded,
|
|
2014
|
-
completedPhases: diskCompletedPhases,
|
|
2015
|
-
totalPlans: diskTotalPlans,
|
|
2016
|
-
completedPlans: diskTotalSummaries,
|
|
2632
|
+
completedPhases: diskCountsWithheld ? null : diskCompletedPhases,
|
|
2633
|
+
totalPlans: diskCountsWithheld ? null : diskTotalPlans,
|
|
2634
|
+
completedPlans: diskCountsWithheld ? null : diskTotalSummaries,
|
|
2017
2635
|
phaseDirScope,
|
|
2018
2636
|
};
|
|
2019
2637
|
})();
|
|
@@ -2031,9 +2649,31 @@ function buildStateFrontmatter(bodyContent, cwd, storedMilestone, storedTotalPha
|
|
|
2031
2649
|
else if (storedTotalPhases !== null && storedTotalPhases !== undefined) {
|
|
2032
2650
|
totalPhases = storedTotalPhases;
|
|
2033
2651
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2652
|
+
// #4094: the same withhold-then-fall-back-to-stored pattern for the
|
|
2653
|
+
// three sibling counters. They are derived from the identical
|
|
2654
|
+
// phaseDirs walk, so cached.* === null here means the SAME withheld
|
|
2655
|
+
// condition — keep the stored frontmatter value when the caller can
|
|
2656
|
+
// supply it; else leave null (key omitted). Note completedPhases /
|
|
2657
|
+
// completedPlans have NO body-annotation fallback (only the totals
|
|
2658
|
+
// have body annotations), so an unstored-withheld counter is omitted.
|
|
2659
|
+
if (cached.completedPhases !== null) {
|
|
2660
|
+
completedPhases = cached.completedPhases;
|
|
2661
|
+
}
|
|
2662
|
+
else if (storedCompletedPhases !== null && storedCompletedPhases !== undefined) {
|
|
2663
|
+
completedPhases = storedCompletedPhases;
|
|
2664
|
+
}
|
|
2665
|
+
if (cached.totalPlans !== null) {
|
|
2666
|
+
totalPlans = cached.totalPlans;
|
|
2667
|
+
}
|
|
2668
|
+
else if (storedTotalPlans !== null && storedTotalPlans !== undefined) {
|
|
2669
|
+
totalPlans = storedTotalPlans;
|
|
2670
|
+
}
|
|
2671
|
+
if (cached.completedPlans !== null) {
|
|
2672
|
+
completedPlans = cached.completedPlans;
|
|
2673
|
+
}
|
|
2674
|
+
else if (storedCompletedPlans !== null && storedCompletedPlans !== undefined) {
|
|
2675
|
+
completedPlans = storedCompletedPlans;
|
|
2676
|
+
}
|
|
2037
2677
|
milestoneUnbounded = cached.milestoneBounded === false;
|
|
2038
2678
|
diskScope = cached.phaseDirScope;
|
|
2039
2679
|
}
|
|
@@ -2325,12 +2965,21 @@ function readStateHeadFreshness(cwd, stateHead) {
|
|
|
2325
2965
|
* instead of being clobbered by the on-disk phase-directory count.
|
|
2326
2966
|
*/
|
|
2327
2967
|
function readStoredTotalPhases(existingFm) {
|
|
2968
|
+
return readStoredProgressCounter(existingFm, 'total_phases');
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* #4094: the three sibling readers of readStoredTotalPhases, one per progress
|
|
2972
|
+
* counter the #3354/#3573 withhold now protects. All four counters come from
|
|
2973
|
+
* the same disk-scan walk and are withheld together; these readers feed the
|
|
2974
|
+
* stored-value fallback for the three that previously had none.
|
|
2975
|
+
*/
|
|
2976
|
+
function readStoredProgressCounter(existingFm, key) {
|
|
2328
2977
|
if (!existingFm || typeof existingFm !== 'object')
|
|
2329
2978
|
return null;
|
|
2330
2979
|
const progress = existingFm['progress'];
|
|
2331
2980
|
if (!progress || typeof progress !== 'object')
|
|
2332
2981
|
return null;
|
|
2333
|
-
const raw = progress[
|
|
2982
|
+
const raw = progress[key];
|
|
2334
2983
|
if (raw === null || raw === undefined)
|
|
2335
2984
|
return null;
|
|
2336
2985
|
if (typeof raw === 'string' && raw.trim() === '')
|
|
@@ -2338,12 +2987,43 @@ function readStoredTotalPhases(existingFm) {
|
|
|
2338
2987
|
const n = Number(raw);
|
|
2339
2988
|
return Number.isFinite(n) ? n : null;
|
|
2340
2989
|
}
|
|
2990
|
+
function readStoredCompletedPhases(existingFm) {
|
|
2991
|
+
return readStoredProgressCounter(existingFm, 'completed_phases');
|
|
2992
|
+
}
|
|
2993
|
+
function readStoredTotalPlans(existingFm) {
|
|
2994
|
+
return readStoredProgressCounter(existingFm, 'total_plans');
|
|
2995
|
+
}
|
|
2996
|
+
function readStoredCompletedPlans(existingFm) {
|
|
2997
|
+
return readStoredProgressCounter(existingFm, 'completed_plans');
|
|
2998
|
+
}
|
|
2341
2999
|
function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanentEmptyFallback) {
|
|
2342
3000
|
// Read existing frontmatter BEFORE stripping — it may contain values
|
|
2343
3001
|
// that the body no longer has (e.g., Status field removed by an agent).
|
|
2344
3002
|
// `cwd` already identifies the workspace this content came from, so the STATE.md path is
|
|
2345
3003
|
// derivable here without widening the signature (#1882).
|
|
2346
3004
|
const existingFm = extractFrontmatter(content, cwd ? planningPaths(cwd).state : undefined);
|
|
3005
|
+
// #3881 review, second round: an UNPARSEABLE frontmatter block (malformed YAML, a git
|
|
3006
|
+
// merge-conflict marker, a refused anchor) must never be silently REPLACED by a freshly
|
|
3007
|
+
// re-derived one — that destroys the only copy of what the block actually contained, with
|
|
3008
|
+
// no signal to the human that their document was in conflict. `beginFrontmatterReassembly`
|
|
3009
|
+
// (state-transition.cts) already preserves the raw fmPrefix through the pure transform
|
|
3010
|
+
// layer for every `transitionCore` kind; this was the gap — this function re-parses the
|
|
3011
|
+
// ALREADY-preserved `content` and, finding {} + the marker, rebuilt a fresh block anyway,
|
|
3012
|
+
// discarding the raw prefix the transform layer had just protected. Confirmed by execution
|
|
3013
|
+
// against `state complete-phase`/`update`/`patch`/`begin-phase`: each returned success with
|
|
3014
|
+
// the conflict markers gone and a freshly-derived, well-formed frontmatter block in their
|
|
3015
|
+
// place (re-derivation, not deletion — the document never lost its frontmatter FENCE).
|
|
3016
|
+
//
|
|
3017
|
+
// `sanctionedPermanentEmptyFallback` is threaded ONLY from `writeStateMd`, itself consumed
|
|
3018
|
+
// ONLY by `cmdStateSync` (#905) and `/gsd-health --repair`'s `REGENERATE_STATE` — ADR-3408
|
|
3019
|
+
// §8.3's CLOSED list of commands whose documented contract is "body wins, re-derive
|
|
3020
|
+
// unconditionally" (a factory reset / explicit resync). Those two are untouched here: this
|
|
3021
|
+
// guard fires only on the OTHER call path (`syncAndPreserveStateMd`, i.e. every
|
|
3022
|
+
// `readModifyWriteStateMd`-based command), where re-deriving over unparseable content was
|
|
3023
|
+
// never the intended contract in the first place — it was an unhandled gap, not a decision.
|
|
3024
|
+
if (!sanctionedPermanentEmptyFallback && isUnparseableFrontmatter(existingFm)) {
|
|
3025
|
+
return content;
|
|
3026
|
+
}
|
|
2347
3027
|
const body = stripFrontmatter(content);
|
|
2348
3028
|
// #3017: pass the stored milestone from the existing frontmatter so
|
|
2349
3029
|
// buildStateFrontmatter scopes its disk scan to the correct milestone
|
|
@@ -2353,7 +3033,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2353
3033
|
// milestoned-but-unbounded withhold can preserve it across the write
|
|
2354
3034
|
// (the derived progress sub-block replaces the stored one wholesale below,
|
|
2355
3035
|
// so an omitted key would otherwise DELETE the stored value).
|
|
2356
|
-
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm));
|
|
3036
|
+
const derivedFm = buildStateFrontmatter(body, cwd, storedMilestone, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
2357
3037
|
// Preserve existing frontmatter status when body-derived status is 'unknown'.
|
|
2358
3038
|
// This prevents a missing Status: field in the body from overwriting a
|
|
2359
3039
|
// previously valid status (e.g., 'executing' → 'unknown').
|
|
@@ -2531,7 +3211,7 @@ function syncStateFrontmatter(content, cwd, authoritativeFm, sanctionedPermanent
|
|
|
2531
3211
|
// #3257: propagate full-line frontmatter comments from the extracted source onto the
|
|
2532
3212
|
// rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
|
|
2533
3213
|
// skip the Symbol-keyed channel, so without this the comments would be lost here even
|
|
2534
|
-
// though
|
|
3214
|
+
// though parseGuardedYamlRegion/reconstructFrontmatter preserve them in isolation).
|
|
2535
3215
|
propagateCommentChannel(existingFm, derivedFm);
|
|
2536
3216
|
const yamlStr = reconstructFrontmatter(derivedFm);
|
|
2537
3217
|
return `---\n${yamlStr}\n---\n\n${body}`;
|
|
@@ -2783,7 +3463,23 @@ function withStateLock(statePath, fn) {
|
|
|
2783
3463
|
* @param clock
|
|
2784
3464
|
* Optional clock seam; defaults to realClock. Passed through to acquireStateLock.
|
|
2785
3465
|
*/
|
|
2786
|
-
|
|
3466
|
+
/**
|
|
3467
|
+
* ADR-3473 §8.6: `writeStateMd` is ADR-3408 §8.3's sanctioned-exception write
|
|
3468
|
+
* path — only a `rebuildStateTransaction` may travel it. Enforced here rather
|
|
3469
|
+
* than left to caller discipline: the transaction TYPE is what makes the two
|
|
3470
|
+
* sanctioned exceptions (`cmdStateSync`, `REGENERATE_STATE`) greppable and
|
|
3471
|
+
* closed, and an `open()` transaction reaching this function would mean a
|
|
3472
|
+
* preservation-governed write silently skipped preservation.
|
|
3473
|
+
*/
|
|
3474
|
+
function writeStateMd(statePath, content, transaction, cwd, clock) {
|
|
3475
|
+
if (transaction.kind !== 'rebuild') {
|
|
3476
|
+
const err = new Error(`writeStateMd: expected a 'rebuild' transaction, got '${transaction.kind}'. writeStateMd is ` +
|
|
3477
|
+
'ADR-3408 §8.3\'s sanctioned-exception write path (cmdStateSync / REGENERATE_STATE only) — ' +
|
|
3478
|
+
'only rebuildStateTransaction() may travel it (ADR-3473 §8.6). An open() transaction here ' +
|
|
3479
|
+
'would silently skip preservation for a write that was supposed to run it.');
|
|
3480
|
+
err.code = 'STATE_TRANSACTION_KIND_INVALID';
|
|
3481
|
+
throw err;
|
|
3482
|
+
}
|
|
2787
3483
|
const lockPath = acquireStateLock(statePath, clock);
|
|
2788
3484
|
// Test seam (audit M8): fire AFTER the lock is taken so a test can simulate a
|
|
2789
3485
|
// concurrent writer landing in the (now-closed) scan→lock window.
|
|
@@ -2805,10 +3501,14 @@ function writeStateMd(statePath, content, cwd, clock) {
|
|
|
2805
3501
|
_diskScanCache.delete(cwd);
|
|
2806
3502
|
// ADR-3408 §8.3: `writeStateMd` is the sole write path for the two
|
|
2807
3503
|
// sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`) —
|
|
2808
|
-
//
|
|
2809
|
-
//
|
|
2810
|
-
// `
|
|
2811
|
-
|
|
3504
|
+
// the sanctioned-permanent empty-field fallback is now DERIVED FROM THE
|
|
3505
|
+
// TRANSACTION KIND (ADR-3473 §8.6) rather than asserted by a literal
|
|
3506
|
+
// `true` at this call site: only a `rebuild` transaction can reach this
|
|
3507
|
+
// function (enforced above), so `transaction.kind === 'rebuild'` is
|
|
3508
|
+
// always `true` here today, but the derivation is what keeps the
|
|
3509
|
+
// fallback's scope tied to the transaction type rather than a
|
|
3510
|
+
// hard-coded constant that could silently drift from it.
|
|
3511
|
+
const synced = syncStateFrontmatter(content, cwd, undefined, transaction.kind === 'rebuild');
|
|
2812
3512
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
2813
3513
|
}
|
|
2814
3514
|
finally {
|
|
@@ -2863,10 +3563,7 @@ function assertStatePreservationOptions(options, caller) {
|
|
|
2863
3563
|
}
|
|
2864
3564
|
function applyPostSyncPreservation(originalContent, transformedContent, syncedContent, statePath, options) {
|
|
2865
3565
|
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);
|
|
3566
|
+
const { resync, authoritativeFm, deriveProgressKeys, divergedFields, explicitProgressField, preWriteState } = options;
|
|
2870
3567
|
// Bug #1230: delta heuristic — snapshot pre-transform body source fields so
|
|
2871
3568
|
// we can detect whether THIS write changed them. syncStateFrontmatter
|
|
2872
3569
|
// re-derives frontmatter status/stopped_at from the body on every write;
|
|
@@ -2877,8 +3574,24 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2877
3574
|
// above (null when resync:true) — these are independent snapshots.
|
|
2878
3575
|
// Strip frontmatter before calling stateExtractField so the YAML `status:`
|
|
2879
3576
|
// key in the frontmatter block cannot shadow the body field we are tracking.
|
|
2880
|
-
const preBody = stripFrontmatter(originalContent);
|
|
2881
3577
|
const preFmSnapshot = extractFrontmatter(originalContent, statePath);
|
|
3578
|
+
// #3881 review, second round: `syncStateFrontmatter` above already declines to re-derive
|
|
3579
|
+
// over an UNPARSEABLE original frontmatter block (its own matching guard), so `syncedContent`
|
|
3580
|
+
// here is `transformedContent` verbatim. But this function's own downstream preservation
|
|
3581
|
+
// machinery (`applyStatePreservation` + the `authoritativeFm` reassertion below) reads
|
|
3582
|
+
// `postFm = extractFrontmatter(syncedContent, ...)` — {} + the marker, since the block still
|
|
3583
|
+
// doesn't parse — restores curated fields from `transaction.snapshot`, and reconstructs a
|
|
3584
|
+
// FRESH frontmatter block from the result, destroying the raw block a second time even
|
|
3585
|
+
// though `syncStateFrontmatter` just finished protecting it. `applyPostSyncPreservation` is
|
|
3586
|
+
// reached ONLY via the non-sanctioned path (`syncAndPreserveStateMd`; `writeStateMd`'s two
|
|
3587
|
+
// ADR-3408 §8.3 closed-list callers — `cmdStateSync` #905 and `/gsd-health --repair`'s
|
|
3588
|
+
// `REGENERATE_STATE` — never call it at all), so this guard needs no extra parameter to stay
|
|
3589
|
+
// scoped off that list. Confirmed by execution: `state begin-phase` on a conflict-marked
|
|
3590
|
+
// STATE.md reached exactly this second clobber even after the `syncStateFrontmatter` fix.
|
|
3591
|
+
if (isUnparseableFrontmatter(preFmSnapshot)) {
|
|
3592
|
+
return transformedContent;
|
|
3593
|
+
}
|
|
3594
|
+
const preBody = stripFrontmatter(originalContent);
|
|
2882
3595
|
const preBodyStatus = (0, state_document_cjs_1.stateExtractField)(preBody, 'Status');
|
|
2883
3596
|
// Bug #1230 / Change B: scope stopped_at delta to the ## Session section,
|
|
2884
3597
|
// mirroring buildStateFrontmatter's sessionBodyScope logic.
|
|
@@ -2958,6 +3671,16 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2958
3671
|
status: { pre: preBodyStatus, post: postBodyStatus },
|
|
2959
3672
|
stopped_at: { pre: preBodyStoppedAt, post: postBodyStoppedAt },
|
|
2960
3673
|
current_phase_name: { pre: preBodyPhaseSource, post: postBodyPhaseSource },
|
|
3674
|
+
// ADR-3473 §8.7 (#3872): `last_activity` is the one `FRONTMATTER_BODY_SOURCE`
|
|
3675
|
+
// key that is NOT `preserve-when-unchanged` (it is `derive` — always
|
|
3676
|
+
// re-stamped from the body) and so was never part of this map before.
|
|
3677
|
+
// Added ONLY for `reconcileReportedFields`'s consumption below (via
|
|
3678
|
+
// `preWriteState.bodyDeltas`) — harmless here, since
|
|
3679
|
+
// `applyPreserveWhenUnchanged` is dispatched by
|
|
3680
|
+
// `getPreserveWhenUnchangedFields()`, never by iterating this object's
|
|
3681
|
+
// keys, so an extra non-preserve-when-unchanged entry changes no
|
|
3682
|
+
// preservation behavior.
|
|
3683
|
+
last_activity: { pre: preBodyLastActivityRaw, post: postBodyLastActivityRaw },
|
|
2961
3684
|
};
|
|
2962
3685
|
// ADR-1769 #1796 (Path A — finish the consolidation): the post-sync
|
|
2963
3686
|
// preservation block is now the pure, table-driven `applyStatePreservation`
|
|
@@ -2977,11 +3700,34 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2977
3700
|
// callers that omit it (readModifyWriteStateMd, cmdPhaseComplete) pay
|
|
2978
3701
|
// nothing extra and see no change to `synced`/the returned content.
|
|
2979
3702
|
const preservationInputSnapshot = divergedFields ? { ...postFm } : null;
|
|
2980
|
-
|
|
2981
|
-
|
|
3703
|
+
// ADR-3473 §8.6: the pre-write snapshot + policy flags now travel as ONE
|
|
3704
|
+
// transaction rather than as a nullable `preFm` alongside the always-present
|
|
3705
|
+
// `preFmSnapshot` (same source, same extractFrontmatter call — `preFm` was
|
|
3706
|
+
// `preFmSnapshot` with the `resync` policy baked in by nulling it, which is
|
|
3707
|
+
// what made `applyPreserveAlways` inert on the default resyncing write
|
|
3708
|
+
// path — #3756).
|
|
3709
|
+
const transaction = stateTransitionMod.openStateTransaction({
|
|
3710
|
+
snapshot: preFmSnapshot,
|
|
3711
|
+
resync,
|
|
2982
3712
|
deriveProgressKeys: deriveProgressKeys === true,
|
|
2983
3713
|
bodyDeltas,
|
|
3714
|
+
explicitProgressField: explicitProgressField === true,
|
|
2984
3715
|
});
|
|
3716
|
+
// ADR-3473 §8.7 (#3872): fill the caller's out-param with the TRANSACTION'S
|
|
3717
|
+
// OWN snapshot object (not a second `extractFrontmatter(originalContent)`
|
|
3718
|
+
// derivation — `transaction.snapshot === preFmSnapshot`, reusing it is the
|
|
3719
|
+
// whole point) plus the pre-write body, so `reconcileReportedFields` can
|
|
3720
|
+
// diff persisted-vs-pre-write instead of re-deriving either side itself.
|
|
3721
|
+
if (preWriteState) {
|
|
3722
|
+
preWriteState.fm = transaction.snapshot;
|
|
3723
|
+
preWriteState.body = preBody;
|
|
3724
|
+
// ADR-3473 §8.7 (#3872): the pre/post body-source delta for every
|
|
3725
|
+
// FRONTMATTER_BODY_SOURCE key — see `StatePreWriteSnapshot`'s docstring
|
|
3726
|
+
// for why `reconcileReportedFields` needs this instead of a raw
|
|
3727
|
+
// frontmatter diff for these specific keys.
|
|
3728
|
+
preWriteState.bodyDeltas = bodyDeltas;
|
|
3729
|
+
}
|
|
3730
|
+
const preservation = applyStatePreservation({ transaction, postFm });
|
|
2985
3731
|
if (divergedFields && preservationInputSnapshot) {
|
|
2986
3732
|
// §8.5's "liberal but visible": every field whose value actually
|
|
2987
3733
|
// differs before vs after `applyStatePreservation` is a field where the
|
|
@@ -2993,10 +3739,13 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
2993
3739
|
for (const key of Object.keys(preservation.postFm)) {
|
|
2994
3740
|
const before = preservationInputSnapshot[key];
|
|
2995
3741
|
const after = preservation.postFm[key];
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3742
|
+
// ADR-3473 §8.7 (#3872 standards-axis finding): route through the ONE
|
|
3743
|
+
// owner of this comparison rule (`stateFieldValuesDiffer`, defined
|
|
3744
|
+
// below) instead of carrying a second inline `JSON.stringify`-vs-`!==`
|
|
3745
|
+
// copy — this is exactly the duplicated-rule shape this epic exists to
|
|
3746
|
+
// remove. `stateFieldValuesDiffer` is a function declaration (hoisted),
|
|
3747
|
+
// so calling it here, above its textual definition, is safe.
|
|
3748
|
+
if (stateFieldValuesDiffer(before, after))
|
|
3000
3749
|
divergedFields.push(key);
|
|
3001
3750
|
}
|
|
3002
3751
|
// ADR-3408 §8.5 Row 2 (D1's actual bug, the reason the guards had to be
|
|
@@ -3042,12 +3791,36 @@ function applyPostSyncPreservation(originalContent, transformedContent, syncedCo
|
|
|
3042
3791
|
}
|
|
3043
3792
|
}
|
|
3044
3793
|
}
|
|
3794
|
+
let finalContent = syncedContent;
|
|
3045
3795
|
if (preservation.mutated || authoritativeReasserted) {
|
|
3796
|
+
// #3742: preservation RESTORES frontmatter keys the body-derived rebuild
|
|
3797
|
+
// could not produce (e.g. `current_phase` on a layout with no body
|
|
3798
|
+
// `**Current Phase:**` line) — but the comment channel was filtered
|
|
3799
|
+
// against the pre-restore key set during sync, so a full-line comment
|
|
3800
|
+
// attached to a restored key died with nothing to re-attach it. Propagate
|
|
3801
|
+
// the channel from the PRE-WRITE snapshot here, after the restores, so a
|
|
3802
|
+
// comment's survival depends on its key surviving the whole write — not
|
|
3803
|
+
// on which body line happened to feed the rebuild. Merge semantics
|
|
3804
|
+
// (propagateCommentChannel) keep any channel the synced content already
|
|
3805
|
+
// carried. No resync gate: this is the RMW path, where `resync` is the
|
|
3806
|
+
// DEFAULT (readModifyWriteStateMd derives it as `options.resync !==
|
|
3807
|
+
// false`) and preservation itself runs regardless — the factory-reset
|
|
3808
|
+
// semantic the #3742 review worried about lives in writeStateMd's
|
|
3809
|
+
// `rebuild` transactions, which never reach this branch.
|
|
3810
|
+
if (preFmSnapshot && !isUnparseableFrontmatter(preFmSnapshot)) {
|
|
3811
|
+
propagateCommentChannel(preFmSnapshot, preservation.postFm);
|
|
3812
|
+
}
|
|
3046
3813
|
const yamlStr = reconstructFrontmatter(preservation.postFm);
|
|
3047
3814
|
const body = stripFrontmatter(syncedContent);
|
|
3048
|
-
|
|
3815
|
+
finalContent = `---\n${yamlStr}\n---\n\n${body}`;
|
|
3816
|
+
}
|
|
3817
|
+
const persistedPercent = (0, state_document_cjs_1.toFiniteNumber)(preservation.postFm['progress'] && preservation.postFm['progress']['percent']);
|
|
3818
|
+
if (persistedPercent !== null) {
|
|
3819
|
+
const reconciled = stateReplaceProgressPercent(finalContent, persistedPercent);
|
|
3820
|
+
if (reconciled !== null)
|
|
3821
|
+
finalContent = reconciled;
|
|
3049
3822
|
}
|
|
3050
|
-
return
|
|
3823
|
+
return finalContent;
|
|
3051
3824
|
}
|
|
3052
3825
|
/**
|
|
3053
3826
|
* ADR-3408 §8.3 — the ONE write-seam composition: `syncStateFrontmatter` then
|
|
@@ -3127,6 +3900,12 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
3127
3900
|
authoritativeFm: options?.authoritativeFm,
|
|
3128
3901
|
deriveProgressKeys: options?.deriveProgressKeys === true,
|
|
3129
3902
|
divergedFields: options?.divergedFields,
|
|
3903
|
+
explicitProgressField: options?.explicitProgressField === true,
|
|
3904
|
+
// ADR-3473 §8.7 (#3872): forwarded so `applyPostSyncPreservation` can
|
|
3905
|
+
// fill it — an unenumerated option here is silently dropped
|
|
3906
|
+
// (Phase 1's commit message; #3871), which is exactly how a prior cut
|
|
3907
|
+
// of this option would have gone missing.
|
|
3908
|
+
preWriteState: options?.preWriteState,
|
|
3130
3909
|
});
|
|
3131
3910
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, synced);
|
|
3132
3911
|
return true;
|
|
@@ -3158,15 +3937,36 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) {
|
|
|
3158
3937
|
* exactly that closed, tested set (`tests/state.test.cjs` A2f pins
|
|
3159
3938
|
* `divergedFields` reporting bare `'progress'`).
|
|
3160
3939
|
*/
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3940
|
+
/**
|
|
3941
|
+
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
|
3942
|
+
* (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key
|
|
3943
|
+
* order — the pre-#3873 literal's own order, which puts `status` AFTER
|
|
3944
|
+
* `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order
|
|
3945
|
+
* in `state-transition.cts`; the two pre-existing tables disagreed with each
|
|
3946
|
+
* other's order too, so each projection reproduces its OWN table's order
|
|
3947
|
+
* rather than a shared derivation). Byte-identical to the pre-#3873 literal:
|
|
3948
|
+
* same 7 keys, same order, same frozen (NOT null-prototype — this table was
|
|
3949
|
+
* a plain `Object.freeze({...})` literal before #3873 and stays one) shape.
|
|
3950
|
+
* `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s
|
|
3951
|
+
* `last_activity` row docstring for the resolved disagreement. Pinned by
|
|
3952
|
+
* `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and
|
|
3953
|
+
* `lastActivityLabelResolutionMatchesShippedBehavior`.
|
|
3954
|
+
*/
|
|
3955
|
+
const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([
|
|
3956
|
+
'current_phase',
|
|
3957
|
+
'current_phase_name',
|
|
3958
|
+
'current_plan',
|
|
3959
|
+
'stopped_at',
|
|
3960
|
+
'paused_at',
|
|
3961
|
+
'status',
|
|
3962
|
+
'last_activity_desc',
|
|
3963
|
+
]);
|
|
3964
|
+
const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze(FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => {
|
|
3965
|
+
const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key];
|
|
3966
|
+
if (row.bodyLabel !== undefined)
|
|
3967
|
+
acc[key] = row.bodyLabel;
|
|
3968
|
+
return acc;
|
|
3969
|
+
}, {}));
|
|
3170
3970
|
/**
|
|
3171
3971
|
* ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields`
|
|
3172
3972
|
* entry. Throws for a `preserve-when-unchanged` field with no
|
|
@@ -3181,9 +3981,19 @@ const FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({
|
|
|
3181
3981
|
* (e.g. `progress`), not a silent degrade.
|
|
3182
3982
|
*/
|
|
3183
3983
|
function bodyLabelFor(field) {
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3984
|
+
// ADR-3473 §8.7 (#3872 review): an OWN-PROPERTY check, never a bare
|
|
3985
|
+
// bracket read — `FRONTMATTER_KEY_TO_BODY_LABEL` is a plain object literal
|
|
3986
|
+
// (real `Object.prototype` in its chain), so `[field]` for a hostile field
|
|
3987
|
+
// named `__proto__`/`constructor`/`toString` returns the INHERITED
|
|
3988
|
+
// prototype-chain member (`Object.prototype` itself, the `Object`
|
|
3989
|
+
// constructor function, `Object.prototype.toString`) instead of
|
|
3990
|
+
// `undefined` — which would then be returned as the "label" and leak a
|
|
3991
|
+
// non-string value into the caller's `updated` array. Proven by
|
|
3992
|
+
// `dottedResolutionDoesNotPollutePrototypes` (test matrix row 25) before
|
|
3993
|
+
// this fix. Mirrors `resolveFrontmatterPath`'s own-property discipline.
|
|
3994
|
+
if (Object.prototype.hasOwnProperty.call(FRONTMATTER_KEY_TO_BODY_LABEL, field)) {
|
|
3995
|
+
return FRONTMATTER_KEY_TO_BODY_LABEL[field];
|
|
3996
|
+
}
|
|
3187
3997
|
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3188
3998
|
if (cls && cls.preservation === 'preserve-when-unchanged') {
|
|
3189
3999
|
const err = new Error(`reconcileReportedFields: preserve-when-unchanged field ${JSON.stringify(field)} has no ` +
|
|
@@ -3196,98 +4006,343 @@ function bodyLabelFor(field) {
|
|
|
3196
4006
|
return field;
|
|
3197
4007
|
}
|
|
3198
4008
|
/**
|
|
3199
|
-
* ADR-
|
|
3200
|
-
*
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3203
|
-
*
|
|
4009
|
+
* ADR-3473 §8.7 (issue #3872): the provenance exclusion — the ONLY
|
|
4010
|
+
* frontmatter key measured to change on EVERY write, regardless of content.
|
|
4011
|
+
* Verified at the CLI (`40-design.md` "Two corrections from reproducing it"):
|
|
4012
|
+
* two content-identical writes to a git-backed fixture differ in exactly
|
|
4013
|
+
* this one key. `state_head` was deliberately measured OUT of this set —
|
|
4014
|
+
* it restamps every write but its PERSISTED VALUE changes only when git HEAD
|
|
4015
|
+
* actually moved, so it tracks a real fact and does not flood.
|
|
4016
|
+
*
|
|
4017
|
+
* A CLOSED, ENUMERATED set — not a predicate or a callback (Greenspun's
|
|
4018
|
+
* Tenth Rule, ADR-3473 §8.7's Laws section: "the moment it takes a callback
|
|
4019
|
+
* it has become the classification table again under a new name"). It
|
|
4020
|
+
* exists to protect `src/state.cts:607` — `state.patch`'s ENTIRE
|
|
4021
|
+
* success/failure signal is `results.updated.length > 0` — admitting an
|
|
4022
|
+
* always-changing key here would make that boolean permanently `true`, so a
|
|
4023
|
+
* fully-failed patch would report success.
|
|
4024
|
+
*/
|
|
4025
|
+
const STATE_UPDATED_PROVENANCE_EXCLUSION = Object.freeze(['last_updated']);
|
|
4026
|
+
/** 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. */
|
|
4027
|
+
const STATE_FIELD_ABSENT = Symbol('state-field-absent');
|
|
4028
|
+
/**
|
|
4029
|
+
* ADR-3473 §8.7 (#3872): resolve `path` against a parsed frontmatter object.
|
|
4030
|
+
* Pure, never throws.
|
|
4031
|
+
*
|
|
4032
|
+
* Order is pinned (test matrix row 26, `literalDottedKeyResolvesBeforePathTraversal`):
|
|
4033
|
+
* a LITERAL flat key wins first — a field name that happens to contain a `.`
|
|
4034
|
+
* but is stored as one flat key must not be shadowed by path traversal —
|
|
4035
|
+
* and only when no literal key exists does `path` get split and walked as a
|
|
4036
|
+
* dotted path.
|
|
4037
|
+
*
|
|
4038
|
+
* Hostile-input rows (23-25 of the test matrix) all resolve to
|
|
4039
|
+
* `STATE_FIELD_ABSENT` rather than throwing: a missing parent, a scalar
|
|
4040
|
+
* parent (`typeof cursor !== 'object'`), and — the prototype-pollution
|
|
4041
|
+
* case — a `__proto__`/`constructor`/`toString` segment. The own-property
|
|
4042
|
+
* check (`Object.prototype.hasOwnProperty.call`, never a bare `in` or
|
|
4043
|
+
* bracket read) is what makes the last one safe: an inherited
|
|
4044
|
+
* `Object.prototype` member is never mistaken for an own data key, and
|
|
4045
|
+
* because this function only ever READS a segment (never assigns one),
|
|
4046
|
+
* no prototype can be polluted by walking it.
|
|
4047
|
+
*/
|
|
4048
|
+
function resolveFrontmatterPath(fm, path) {
|
|
4049
|
+
if (Object.prototype.hasOwnProperty.call(fm, path))
|
|
4050
|
+
return fm[path];
|
|
4051
|
+
if (!path.includes('.'))
|
|
4052
|
+
return STATE_FIELD_ABSENT;
|
|
4053
|
+
let cursor = fm;
|
|
4054
|
+
for (const segment of path.split('.')) {
|
|
4055
|
+
if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor))
|
|
4056
|
+
return STATE_FIELD_ABSENT;
|
|
4057
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, segment))
|
|
4058
|
+
return STATE_FIELD_ABSENT;
|
|
4059
|
+
cursor = cursor[segment];
|
|
4060
|
+
}
|
|
4061
|
+
return cursor;
|
|
4062
|
+
}
|
|
4063
|
+
/**
|
|
4064
|
+
* ADR-3473 §8.7 (#3872): representation-insensitive equality for a
|
|
4065
|
+
* persisted-vs-snapshot leaf value (test matrix rows 21/22). Frontmatter
|
|
4066
|
+
* scalars round-trip as STRINGS (`extractFrontmatter`, §8.1's open type
|
|
4067
|
+
* question) while an in-memory derivation can hold a real number or boolean
|
|
4068
|
+
* — a naive `!==` would report every numeric/boolean field changed on every
|
|
4069
|
+
* write. Mirrors the existing `divergedFields` diff's typeof-object branch
|
|
4070
|
+
* in `applyPostSyncPreservation` (JSON.stringify for objects, else a
|
|
4071
|
+
* normalized scalar compare) rather than inventing a second comparison.
|
|
4072
|
+
* Presence-vs-absence (`STATE_FIELD_ABSENT` on exactly one side) is always a
|
|
4073
|
+
* change — a deleted or newly-added key (test matrix rows 16/17) — never
|
|
4074
|
+
* folded into the scalar branch below it.
|
|
4075
|
+
*/
|
|
4076
|
+
/**
|
|
4077
|
+
* ADR-3473 §8.7 (#3872): `String(v)` on an `unknown` is unsafe (a hostile
|
|
4078
|
+
* object could carry a custom, throwing, or `[object Object]`-degrading
|
|
4079
|
+
* `toString`) — narrowed per-branch here so each `String()` call below only
|
|
4080
|
+
* ever runs on a primitive TypeScript itself knows is safe to stringify.
|
|
4081
|
+
*/
|
|
4082
|
+
function stateScalarString(v) {
|
|
4083
|
+
if (v === null || v === undefined)
|
|
4084
|
+
return '';
|
|
4085
|
+
if (typeof v === 'string')
|
|
4086
|
+
return v;
|
|
4087
|
+
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
|
|
4088
|
+
return String(v);
|
|
4089
|
+
return JSON.stringify(v) ?? '';
|
|
4090
|
+
}
|
|
4091
|
+
function stateFieldValuesDiffer(before, after) {
|
|
4092
|
+
if (before === STATE_FIELD_ABSENT && after === STATE_FIELD_ABSENT)
|
|
4093
|
+
return false;
|
|
4094
|
+
if (before === STATE_FIELD_ABSENT || after === STATE_FIELD_ABSENT)
|
|
4095
|
+
return true;
|
|
4096
|
+
if (typeof before === 'object' || typeof after === 'object') {
|
|
4097
|
+
return JSON.stringify(before) !== JSON.stringify(after);
|
|
4098
|
+
}
|
|
4099
|
+
return stateScalarString(before).trim() !== stateScalarString(after).trim();
|
|
4100
|
+
}
|
|
4101
|
+
/**
|
|
4102
|
+
* ADR-3473 §8.7 (#3872): the declared dotted-leaf children of a frontmatter
|
|
4103
|
+
* key, read off `FIELD_CLASSIFICATION` (`progress` -> its five
|
|
4104
|
+
* `progress.*` rows) rather than walked from arbitrary nesting depth of a
|
|
4105
|
+
* user-authored document. A BOUNDED, DECLARED enumeration — the design
|
|
4106
|
+
* doc's Rejected #5 and the "Emit dotted leaves, not the parent" rule both
|
|
4107
|
+
* depend on this staying a closed set the schema names, not unbounded
|
|
4108
|
+
* traversal of whatever object shape happens to be on disk.
|
|
4109
|
+
*/
|
|
4110
|
+
function declaredLeavesOf(key) {
|
|
4111
|
+
const prefix = `${key}.`;
|
|
4112
|
+
return Object.keys(FIELD_CLASSIFICATION).filter((k) => k.startsWith(prefix));
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* ADR-3473 §8.7 (#3872): every frontmatter key — resolved at DOTTED-LEAF
|
|
4116
|
+
* granularity for a key with declared leaves (`progress` -> only the
|
|
4117
|
+
* `progress.*` leaves that actually moved, never bare `progress` itself;
|
|
4118
|
+
* design doc rule 4/Rejected #5) — whose PERSISTED value differs from the
|
|
4119
|
+
* transaction's pre-write SNAPSHOT. Pure: no I/O, no `FIELD_CLASSIFICATION`
|
|
4120
|
+
* preservation-policy consultation (that filter is exactly what this rule
|
|
4121
|
+
* deletes — ADR-3473 §8.7 "no field is excluded by classification").
|
|
4122
|
+
* `last_updated` is the one-element provenance exclusion; every other key,
|
|
4123
|
+
* including `state_head`, is a candidate.
|
|
4124
|
+
*
|
|
4125
|
+
* **A `FRONTMATTER_BODY_SOURCE` key is diffed via `bodyDeltas`, never via a
|
|
4126
|
+
* raw frontmatter compare.** Found while driving the #1264 regression check
|
|
4127
|
+
* through this rewrite at the CLI: `syncStateFrontmatter` re-derives EVERY
|
|
4128
|
+
* body-sourced key into frontmatter on EVERY write, independent of whether
|
|
4129
|
+
* this write's own transform touched it. A hand-authored (or day-1
|
|
4130
|
+
* bootstrap) STATE.md whose frontmatter has not yet caught up to an
|
|
4131
|
+
* already-stable body value — e.g. `current_phase_name` present in the body
|
|
4132
|
+
* but absent from a pre-write frontmatter block that only ever recorded
|
|
4133
|
+
* `status`/`progress` — makes that key look newly ADDED under a raw diff
|
|
4134
|
+
* (rows 15/17) even though nothing changed. The real "did THIS write change
|
|
4135
|
+
* it" signal for these keys is whether their BODY SOURCE moved, which is
|
|
4136
|
+
* exactly what `bodyDeltas` (built once, in `applyPostSyncPreservation`,
|
|
4137
|
+
* from `originalContent` vs `transformedContent`) already answers — reused
|
|
4138
|
+
* here rather than re-derived, and it is what correctly REPORTS #3818's
|
|
4139
|
+
* `current_phase` (the body source did move) while staying SILENT on a
|
|
4140
|
+
* merely-backfilled, body-unchanged key (the #1264 false positive this
|
|
4141
|
+
* function's first cut produced).
|
|
4142
|
+
*
|
|
4143
|
+
* **A declared dotted-leaf (`declaredLeavesOf`, e.g. every `progress.*` row)
|
|
4144
|
+
* absent from the snapshot and present in persisted is materialization, not
|
|
4145
|
+
* a change.** Found the same way as the paragraph above, one layer down:
|
|
4146
|
+
* `progress` is `source: 'disk'` (state-transition.cts), re-derived by
|
|
4147
|
+
* `buildStateFrontmatter`'s phase-directory scan on every write regardless
|
|
4148
|
+
* of whether the caller's own action touched it — and the phases directory
|
|
4149
|
+
* cannot move during a STATE.md write, so a fresh `progress` block appearing
|
|
4150
|
+
* where the snapshot had none is the scanner catching a never-synced
|
|
4151
|
+
* document up, not the caller changing anything. This is the SAME
|
|
4152
|
+
* provenance principle `STATE_UPDATED_PROVENANCE_EXCLUSION` applies to
|
|
4153
|
+
* `last_updated` (a field stamped by the write's occurrence, not its
|
|
4154
|
+
* action) — generalized to the declared-leaf case, deliberately NOT a
|
|
4155
|
+
* second classification-based exclusion: `progress`'s `preserve-always`
|
|
4156
|
+
* policy plays no part in the check below, and a leaf already PRESENT in
|
|
4157
|
+
* the snapshot is diffed exactly as every other field is, including
|
|
4158
|
+
* reporting its outright disappearance (row 16) — only the absent-in-
|
|
4159
|
+
* snapshot-but-materialized-in-persisted transition is suppressed.
|
|
4160
|
+
*/
|
|
4161
|
+
function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
|
|
4162
|
+
const changed = [];
|
|
4163
|
+
const topKeys = new Set([...Object.keys(snapshotFm), ...Object.keys(persistedFm)]);
|
|
4164
|
+
for (const key of topKeys) {
|
|
4165
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(key))
|
|
4166
|
+
continue;
|
|
4167
|
+
if (stateTransitionMod.getFrontmatterBodySource(key) !== null) {
|
|
4168
|
+
const delta = bodyDeltas ? bodyDeltas[key] : undefined;
|
|
4169
|
+
if (delta && stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT)) {
|
|
4170
|
+
changed.push(key);
|
|
4171
|
+
}
|
|
4172
|
+
continue;
|
|
4173
|
+
}
|
|
4174
|
+
const leaves = declaredLeavesOf(key);
|
|
4175
|
+
if (leaves.length > 0) {
|
|
4176
|
+
for (const leaf of leaves) {
|
|
4177
|
+
const before = resolveFrontmatterPath(snapshotFm, leaf);
|
|
4178
|
+
const after = resolveFrontmatterPath(persistedFm, leaf);
|
|
4179
|
+
// Generalizes the SAME provenance principle STATE_UPDATED_PROVENANCE_EXCLUSION
|
|
4180
|
+
// applies to `last_updated` one level up — this is NOT a classification-based
|
|
4181
|
+
// exclusion (progress's `preserve-always` policy plays no part here; that filter
|
|
4182
|
+
// stays deleted per §8.7). It is a fact about the DECLARED LEAF SET: every key
|
|
4183
|
+
// enumerated by `declaredLeavesOf` is `source: 'disk'` (state-transition.cts),
|
|
4184
|
+
// re-derived from a scan that cannot move during a STATE.md write (the write only
|
|
4185
|
+
// touches STATE.md, never the phases directory). So a leaf ABSENT from the
|
|
4186
|
+
// pre-write snapshot and PRESENT in persisted is the scanner catching a document
|
|
4187
|
+
// up to a derivation it had never synced before — the write's own OCCURRENCE
|
|
4188
|
+
// produced the bytes, not the caller's ACTION, exactly the `last_updated` shape.
|
|
4189
|
+
// A leaf already PRESENT in the snapshot behaves normally: any difference
|
|
4190
|
+
// (including disappearing entirely, row 16) is reported, because there the
|
|
4191
|
+
// snapshot proves the derivation had already run once, so a new persisted value
|
|
4192
|
+
// can only come from something genuinely moving (#3743/#3818).
|
|
4193
|
+
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
4194
|
+
continue;
|
|
4195
|
+
if (stateFieldValuesDiffer(before, after))
|
|
4196
|
+
changed.push(leaf);
|
|
4197
|
+
}
|
|
4198
|
+
continue;
|
|
4199
|
+
}
|
|
4200
|
+
const before = resolveFrontmatterPath(snapshotFm, key);
|
|
4201
|
+
const after = resolveFrontmatterPath(persistedFm, key);
|
|
4202
|
+
if (stateFieldValuesDiffer(before, after))
|
|
4203
|
+
changed.push(key);
|
|
4204
|
+
}
|
|
4205
|
+
return changed;
|
|
4206
|
+
}
|
|
4207
|
+
/**
|
|
4208
|
+
* ADR-3473 §8.7 (issue #3872): the transaction diff. `updated` is derived
|
|
4209
|
+
* by comparing PERSISTED frontmatter against the transaction's pre-write
|
|
4210
|
+
* SNAPSHOT — replacing the prior comparison of the transform's own OUTPUT
|
|
4211
|
+
* against persisted bytes, which answered a different question ("did the
|
|
4212
|
+
* transform's write survive to disk", #3351) from the one §8.7 asks ("what
|
|
4213
|
+
* did this write actually change" — both #3351's direction and #3345/#3818's
|
|
4214
|
+
* fall out of ONE comparison against the pre-write state; see the design
|
|
4215
|
+
* doc's "ambiguity in §8.7" section for why the transform-output comparison
|
|
4216
|
+
* was rejected).
|
|
3204
4217
|
*
|
|
3205
|
-
*
|
|
3206
|
-
*
|
|
3207
|
-
*
|
|
3208
|
-
*
|
|
3209
|
-
*
|
|
3210
|
-
*
|
|
3211
|
-
*
|
|
3212
|
-
* knew about.
|
|
4218
|
+
* No field is excluded by classification — `getFieldClassification` /
|
|
4219
|
+
* `preservation !== 'preserve-when-unchanged'` is gone, not relocated. The
|
|
4220
|
+
* ONLY exclusion is `STATE_UPDATED_PROVENANCE_EXCLUSION` (provenance, not
|
|
4221
|
+
* classification): an unchanged `progress` no longer needs a special filter
|
|
4222
|
+
* to stay unreported (#1264) because the diff itself says "unchanged" —
|
|
4223
|
+
* and a GENUINELY changed `progress.*` leaf (#3743, #3818) is no longer
|
|
4224
|
+
* suppressed by the same filter.
|
|
3213
4225
|
*
|
|
3214
|
-
* @param
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
3218
|
-
*
|
|
3219
|
-
*
|
|
3220
|
-
*
|
|
3221
|
-
*
|
|
3222
|
-
*
|
|
3223
|
-
* labels (`Status`, `Current Plan`
|
|
3224
|
-
*
|
|
3225
|
-
*
|
|
3226
|
-
*
|
|
3227
|
-
*
|
|
4226
|
+
* @param preWriteState The transaction's pre-write snapshot + body — the
|
|
4227
|
+
* `preWriteState` out-param `applyPostSyncPreservation` filled during
|
|
4228
|
+
* THIS write (see `ReadModifyWriteOptions.preWriteState`'s docstring).
|
|
4229
|
+
* `.fm`/`.body` are `undefined` only when `readModifyWriteStateMd`'s own
|
|
4230
|
+
* #948 no-op guard fired (transform output was byte-identical to input),
|
|
4231
|
+
* in which case nothing was ever written and `[]` is the correct,
|
|
4232
|
+
* short-circuited answer — never a diff against a synthesized empty `{}`
|
|
4233
|
+
* snapshot, which would read every already-persisted key as newly ADDED.
|
|
4234
|
+
* @param reported The candidate field names — the transform's OWN success
|
|
4235
|
+
* list. Body Title-Case labels (`Status`, `Current Plan`, `Current
|
|
4236
|
+
* Position`) and frontmatter keys (including dotted leaves like
|
|
4237
|
+
* `progress.total_plans`) are both valid; each is resolved via the same
|
|
4238
|
+
* `valueOf` fallback chain used for the inclusion test below.
|
|
4239
|
+
* @param divergedFields Kept for signature/out-param stability (ADR-3408
|
|
4240
|
+
* §8.5) — populated exactly as before by `applyPostSyncPreservation` and
|
|
4241
|
+
* still read directly by other code and `tests/state.test.cjs`'s A2f case
|
|
4242
|
+
* — but no longer consulted here as a candidate SOURCE (design doc row
|
|
4243
|
+
* 18): the frontmatter diff subsumes what it used to contribute, and it
|
|
4244
|
+
* sees only what *preservation* changed, never what *sync* changed
|
|
4245
|
+
* (#3818's own direction), which is why keeping it as the candidate
|
|
4246
|
+
* source was rejected (design doc, Rejected #1).
|
|
3228
4247
|
*/
|
|
3229
|
-
function reconcileReportedFields(statePath,
|
|
4248
|
+
function reconcileReportedFields(statePath, preWriteState, reported, divergedFields) {
|
|
4249
|
+
void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
|
|
4250
|
+
if (preWriteState.fm === undefined || preWriteState.body === undefined)
|
|
4251
|
+
return [];
|
|
3230
4252
|
const persisted = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
3231
4253
|
const persistedFm = extractFrontmatter(persisted, statePath);
|
|
3232
4254
|
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").
|
|
4255
|
+
const snapshotFm = preWriteState.fm;
|
|
4256
|
+
const snapshotBody = preWriteState.body;
|
|
4257
|
+
// #3471 review (unchanged by this rewrite): body-FIRST, frontmatter-key-
|
|
4258
|
+
// FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
|
|
4259
|
+
// write precedence `patchCore`/`updateCore` apply (#1162's fix — a
|
|
4260
|
+
// lowercase body label that happens to case-exact-match a frontmatter key
|
|
4261
|
+
// must still resolve against the body). `field` a literal flat key (even
|
|
4262
|
+
// one containing a `.`) is tried before it is split and walked as a
|
|
4263
|
+
// dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
|
|
4264
|
+
// same order for the frontmatter side alone.
|
|
4265
|
+
//
|
|
4266
|
+
// `Current Position` is special-cased: it names the WHOLE `## Current
|
|
4267
|
+
// Position` section, not a single `Label: value` line, so
|
|
4268
|
+
// `stateExtractField` can never resolve it (this is the root cause of the
|
|
4269
|
+
// "Current Position undercount" — a transform can correctly push
|
|
4270
|
+
// `'Current Position'` into its own `updated` list, and this function
|
|
4271
|
+
// still silently dropped it, because `valueOf` returned `null` for BOTH
|
|
4272
|
+
// sides and `null === null` failed the old `intended !== null` guard).
|
|
4273
|
+
// `sliceCurrentPositionSection` is the existing fence-aware section
|
|
4274
|
+
// locator (state-transition.cts) — reused rather than re-derived.
|
|
3255
4275
|
const valueOf = (fm, body, field) => {
|
|
4276
|
+
if (field === 'Current Position') {
|
|
4277
|
+
const section = stateTransitionMod.sliceCurrentPositionSection(body);
|
|
4278
|
+
return section !== null ? section.trim() : null;
|
|
4279
|
+
}
|
|
3256
4280
|
const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
|
|
3257
4281
|
if (bodyValue !== null)
|
|
3258
4282
|
return bodyValue;
|
|
3259
|
-
|
|
4283
|
+
if (Object.prototype.hasOwnProperty.call(fm, field))
|
|
4284
|
+
return String(fm[field]);
|
|
4285
|
+
if (field.includes('.')) {
|
|
4286
|
+
const resolved = resolveFrontmatterPath(fm, field);
|
|
4287
|
+
if (resolved !== STATE_FIELD_ABSENT) {
|
|
4288
|
+
return stateScalarString(resolved);
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
return null;
|
|
4292
|
+
};
|
|
4293
|
+
// A field in `reported` can itself be a declared derived leaf (e.g.
|
|
4294
|
+
// `plannedPhaseCore` pushing `'progress.total_plans'` — state-
|
|
4295
|
+
// transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
|
|
4296
|
+
// "absent from the frontmatter" apart from "resolved to the literal string
|
|
4297
|
+
// 'null'/''", so it cannot carry the same materialization rule
|
|
4298
|
+
// `computeChangedFrontmatterFields` applies below. Route these fields
|
|
4299
|
+
// through the SAME primitives (`resolveFrontmatterPath` + the
|
|
4300
|
+
// `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
|
|
4301
|
+
// second, parallel absence convention — one rule, reused, not duplicated.
|
|
4302
|
+
const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
|
|
4303
|
+
const changed = (field) => {
|
|
4304
|
+
if (isDeclaredDerivedLeaf(field)) {
|
|
4305
|
+
const before = resolveFrontmatterPath(snapshotFm, field);
|
|
4306
|
+
const after = resolveFrontmatterPath(persistedFm, field);
|
|
4307
|
+
// Same generalized provenance rule as computeChangedFrontmatterFields:
|
|
4308
|
+
// absent-in-snapshot-materializing-in-persisted is the disk scan
|
|
4309
|
+
// catching a never-synced document up, not this write's own action.
|
|
4310
|
+
if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
|
|
4311
|
+
return false;
|
|
4312
|
+
return stateFieldValuesDiffer(before, after);
|
|
4313
|
+
}
|
|
4314
|
+
const before = valueOf(snapshotFm, snapshotBody, field);
|
|
4315
|
+
const after = valueOf(persistedFm, persistedBody, field);
|
|
4316
|
+
if (before === null && after === null)
|
|
4317
|
+
return false;
|
|
4318
|
+
if (before === null || after === null)
|
|
4319
|
+
return true;
|
|
4320
|
+
return before.trim() !== after.trim();
|
|
3260
4321
|
};
|
|
4322
|
+
// Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
|
|
4323
|
+
// granularity) whose persisted value differs from the snapshot, minus the
|
|
4324
|
+
// provenance exclusion. A frontmatter-diff-discovered field is mapped
|
|
4325
|
+
// through `bodyLabelFor` so it lands in the SAME output vocabulary a
|
|
4326
|
+
// transform would have used (`status` -> `'Status'`; `progress.total_plans`
|
|
4327
|
+
// has no body-line label and falls through to its raw dotted key, same as
|
|
4328
|
+
// today's `progress`/`milestone*` fall-through).
|
|
4329
|
+
const changedFrontmatterFields = computeChangedFrontmatterFields(snapshotFm, persistedFm, preWriteState.bodyDeltas);
|
|
4330
|
+
const mappedFrontmatterFields = changedFrontmatterFields.map((field) => bodyLabelFor(field));
|
|
4331
|
+
const seen = new Set();
|
|
3261
4332
|
const reconciled = [];
|
|
3262
4333
|
for (const field of reported) {
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
if (
|
|
4334
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
|
|
4335
|
+
continue;
|
|
4336
|
+
if (changed(field)) {
|
|
4337
|
+
seen.add(field);
|
|
3266
4338
|
reconciled.push(field);
|
|
3267
4339
|
}
|
|
3268
4340
|
}
|
|
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')
|
|
4341
|
+
for (const field of mappedFrontmatterFields) {
|
|
4342
|
+
if (STATE_UPDATED_PROVENANCE_EXCLUSION.includes(field) || seen.has(field))
|
|
3287
4343
|
continue;
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
reconciled.push(label);
|
|
4344
|
+
seen.add(field);
|
|
4345
|
+
reconciled.push(field);
|
|
3291
4346
|
}
|
|
3292
4347
|
return reconciled;
|
|
3293
4348
|
}
|
|
@@ -3310,7 +4365,7 @@ function cmdStateJson(cwd, raw) {
|
|
|
3310
4365
|
// reports the phase-directory count while the persisted file preserves the
|
|
3311
4366
|
// stored total, exactly the write/read divergence #3354 closed for its shape.
|
|
3312
4367
|
const storedMilestoneJson = typeof existingFm['milestone'] === 'string' ? existingFm['milestone'] : null;
|
|
3313
|
-
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm));
|
|
4368
|
+
const built = buildStateFrontmatter(body, cwd, storedMilestoneJson, readStoredTotalPhases(existingFm), readStoredCompletedPhases(existingFm), readStoredTotalPlans(existingFm), readStoredCompletedPlans(existingFm));
|
|
3314
4369
|
// ADR-3408 §8.5 / D3: route stopped_at / paused_at / status / current_phase /
|
|
3315
4370
|
// current_phase_name / current_plan through the SAME `preserve-when-unchanged`
|
|
3316
4371
|
// executor the write path uses (`applyPreserveWhenUnchanged`), instead of a
|
|
@@ -3342,11 +4397,21 @@ function cmdStateJson(cwd, raw) {
|
|
|
3342
4397
|
?? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionScope, 'Phase')).phase;
|
|
3343
4398
|
const bodyCurrentPlan = (0, state_document_cjs_1.stateExtractField)(body, 'Current Plan');
|
|
3344
4399
|
const bodyStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status');
|
|
4400
|
+
// #3836: mirrors applyPostSyncPreservation's own derivation (state.cts
|
|
4401
|
+
// bodyDeltas, `last_activity_desc`) — the `Last Activity Description`
|
|
4402
|
+
// label, falling back to the prose `Last Activity:` line's parsed
|
|
4403
|
+
// description. Read-side twin of #3258's write-side wiring; this field is
|
|
4404
|
+
// `preserve-when-unchanged` per FIELD_CLASSIFICATION and was previously
|
|
4405
|
+
// absent from this read path entirely (never derived here, never in the
|
|
4406
|
+
// loop below), so a stale body annotation always beat a fresher curated
|
|
4407
|
+
// frontmatter value on every `state json` read.
|
|
4408
|
+
const bodyLastActivityRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity') ?? (0, state_document_cjs_1.stateExtractField)(body, 'Last activity');
|
|
4409
|
+
const bodyLastActivityDesc = (0, state_document_cjs_1.stateExtractField)(body, 'Last Activity Description')
|
|
4410
|
+
?? parseProseLastActivityField(bodyLastActivityRaw).description;
|
|
3345
4411
|
const unchanged = (v) => ({ pre: v, post: v });
|
|
3346
4412
|
const ctx = {
|
|
3347
|
-
preFm: null,
|
|
3348
4413
|
postFm: built,
|
|
3349
|
-
|
|
4414
|
+
snapshot: existingFm,
|
|
3350
4415
|
resync: true,
|
|
3351
4416
|
deriveProgressKeys: false,
|
|
3352
4417
|
bodyDeltas: {
|
|
@@ -3356,10 +4421,15 @@ function cmdStateJson(cwd, raw) {
|
|
|
3356
4421
|
current_phase: unchanged(bodyCurrentPhase),
|
|
3357
4422
|
current_plan: unchanged(bodyCurrentPlan),
|
|
3358
4423
|
current_phase_name: unchanged(bodyPhaseSource),
|
|
4424
|
+
last_activity_desc: unchanged(bodyLastActivityDesc),
|
|
3359
4425
|
},
|
|
3360
4426
|
mutated: false,
|
|
3361
4427
|
};
|
|
3362
|
-
|
|
4428
|
+
// #3836: derive the field set from FIELD_CLASSIFICATION's
|
|
4429
|
+
// `preserve-when-unchanged` rows (single source of truth) instead of a
|
|
4430
|
+
// hand-typed literal that can drift from the table — this IS the fix,
|
|
4431
|
+
// not merely an addition of one more name to the literal.
|
|
4432
|
+
for (const field of stateTransitionMod.getPreserveWhenUnchangedFields()) {
|
|
3363
4433
|
const cls = stateTransitionMod.getFieldClassification(field);
|
|
3364
4434
|
if (cls)
|
|
3365
4435
|
stateTransitionMod.applyPreserveWhenUnchanged(field, cls, ctx);
|
|
@@ -3412,26 +4482,28 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3412
4482
|
// still runs after the sync; the override is re-asserted after it inside
|
|
3413
4483
|
// readModifyWriteStateMd for layouts with no body `Phase:` line.
|
|
3414
4484
|
const divergedFields = [];
|
|
4485
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
4486
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
4487
|
+
const preWriteState = {};
|
|
3415
4488
|
const rmwOptions = {
|
|
3416
4489
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3417
4490
|
divergedFields,
|
|
4491
|
+
preWriteState,
|
|
3418
4492
|
};
|
|
3419
4493
|
let precomputedUpdated = [];
|
|
3420
|
-
let preSyncContent = '';
|
|
3421
4494
|
// #3311: begin-phase is the claim point — it is the one Current Position
|
|
3422
4495
|
// transition that explicitly names its phase, so it both records this
|
|
3423
4496
|
// session's claim and detects a conflicting live claim for a different
|
|
3424
4497
|
// phase. The check runs INSIDE the STATE.md lock so concurrent begin-phase
|
|
3425
4498
|
// calls cannot both read "no claim" and both write.
|
|
3426
4499
|
let milestoneConflict = null;
|
|
3427
|
-
readModifyWriteStateMd(statePath, (content) => {
|
|
4500
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
3428
4501
|
milestoneConflict = milestoneLockMod.claimMilestonePhase(cwd, String(phaseNumber));
|
|
3429
4502
|
if (milestoneConflict) {
|
|
3430
4503
|
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `state.begin-phase ${phaseNumber}`);
|
|
3431
4504
|
}
|
|
3432
4505
|
const result = transitionCore(content, intent, deps);
|
|
3433
4506
|
precomputedUpdated = result.updated;
|
|
3434
|
-
preSyncContent = result.content;
|
|
3435
4507
|
// #3127 resume: the core preserved the mid-flight Current Phase Name, so
|
|
3436
4508
|
// the intent-first override must not fire — it would drift frontmatter
|
|
3437
4509
|
// away from the preserved body value. Dropping it here is safe because
|
|
@@ -3445,8 +4517,29 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3445
4517
|
// the bytes actually persisted (fix(#3351) generalized) and fold in any
|
|
3446
4518
|
// field preservation restored that this transform never touched (#3345's
|
|
3447
4519
|
// direction).
|
|
3448
|
-
const updated = reconcileReportedFields(statePath,
|
|
4520
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
3449
4521
|
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null, milestone_conflict: milestoneConflict }, raw, updated.length > 0 ? 'true' : 'false');
|
|
4522
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on `wrote`
|
|
4523
|
+
// (readModifyWriteStateMd's own return value — its #948 no-op guard skips
|
|
4524
|
+
// the write outright when the transform produced no diff), not on
|
|
4525
|
+
// `updated.length > 0`. Confirmed reproducer: an unrecognized-format
|
|
4526
|
+
// STATE.md makes `beginPhaseCore` match zero body fields AND leave
|
|
4527
|
+
// `existingFm` untouched, so the raw transform output is byte-identical to
|
|
4528
|
+
// the input, the RMW guard fires, and `wrote` is false — matching
|
|
4529
|
+
// `updated: []` here. Unlike `cmdStatePlannedPhase` (which must NOT use
|
|
4530
|
+
// this same `wrote` signal — see its comment for why `plannedPhaseCore`
|
|
4531
|
+
// mutates frontmatter in place even on this exact no-op shape),
|
|
4532
|
+
// `beginPhaseCore` never mutates `existingFm`, so `wrote` and
|
|
4533
|
+
// `updated.length > 0` agree on every case audited for this phase; `wrote`
|
|
4534
|
+
// is kept as the gate here (and on `cmdStateAdvancePlan`/
|
|
4535
|
+
// `cmdStateCompletePhase` below, where it is REQUIRED — `updated`/
|
|
4536
|
+
// `reconciled` can be non-empty there even when nothing was written,
|
|
4537
|
+
// confirmed by direct re-invocation) for one consistent rule across every
|
|
4538
|
+
// RMW-backed command in this file: publish iff `readModifyWriteStateMd`
|
|
4539
|
+
// itself reports a write. Best-effort — cannot throw, cannot change this
|
|
4540
|
+
// command's exit code or output.
|
|
4541
|
+
if (wrote)
|
|
4542
|
+
publishStateContract(cwd);
|
|
3450
4543
|
}
|
|
3451
4544
|
/**
|
|
3452
4545
|
* Write a WAITING.json signal file when GSD hits a decision point.
|
|
@@ -3698,19 +4791,38 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3698
4791
|
// line, and the prose re-derivation of current_phase_name truncates names
|
|
3699
4792
|
// that themselves contain a parenthetical — the authoritative override keeps
|
|
3700
4793
|
// the exact value, exactly as cmdStateBeginPhase does for its EXECUTING line.
|
|
4794
|
+
//
|
|
4795
|
+
// #3834: without a name, the body-source delta rule that would normally
|
|
4796
|
+
// preserve the curated `current_phase_name` (FIELD_CLASSIFICATION:
|
|
4797
|
+
// preserve-when-unchanged) cannot fire — THIS write rewrites the `Phase:`
|
|
4798
|
+
// source line to `N — READY TO EXECUTE` itself, so pre/post disagree by
|
|
4799
|
+
// construction and the post-sync re-derivation harvests "READY TO EXECUTE"
|
|
4800
|
+
// as if it were the name. The fix mirrors the named-arg path: reassert an
|
|
4801
|
+
// authoritative override, falling back to the pre-write curated value (read
|
|
4802
|
+
// inside the RMW callback, before this write's own body mutation) rather
|
|
4803
|
+
// than leaving the field to a delta heuristic this exact transition defeats.
|
|
3701
4804
|
const divergedFields = [];
|
|
4805
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
4806
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
4807
|
+
const preWriteState = {};
|
|
3702
4808
|
const rmwOptions = {
|
|
3703
4809
|
resync: false,
|
|
3704
4810
|
deriveProgressKeys: true,
|
|
3705
4811
|
authoritativeFm: intent.phaseName ? { current_phase_name: intent.phaseName } : undefined,
|
|
3706
4812
|
divergedFields,
|
|
4813
|
+
preWriteState,
|
|
3707
4814
|
};
|
|
3708
4815
|
let precomputedUpdated = [];
|
|
3709
|
-
let preSyncContent = '';
|
|
3710
4816
|
readModifyWriteStateMd(statePath, (content) => {
|
|
4817
|
+
if (!intent.phaseName) {
|
|
4818
|
+
const preFm = extractFrontmatter(content, statePath);
|
|
4819
|
+
const curatedName = preFm['current_phase_name'];
|
|
4820
|
+
if (typeof curatedName === 'string' && curatedName.trim().length > 0) {
|
|
4821
|
+
rmwOptions.authoritativeFm = { current_phase_name: curatedName };
|
|
4822
|
+
}
|
|
4823
|
+
}
|
|
3711
4824
|
const result = transitionCore(content, intent, deps);
|
|
3712
4825
|
precomputedUpdated = result.updated;
|
|
3713
|
-
preSyncContent = result.content;
|
|
3714
4826
|
return result.content;
|
|
3715
4827
|
}, cwd, rmwOptions);
|
|
3716
4828
|
// ADR-3408 §8.4 (D4): reconcile `plannedPhaseCore`'s own success list
|
|
@@ -3719,11 +4831,35 @@ function cmdStatePlannedPhase(cwd, phaseNumber, phaseName, planCount, raw) {
|
|
|
3719
4831
|
// (#3345's direction) — traced for this phase (design doc: "not traced in
|
|
3720
4832
|
// the analysis pass") and found to need exactly the same treatment as
|
|
3721
4833
|
// `cmdStateBeginPhase`.
|
|
3722
|
-
const updated = reconcileReportedFields(statePath,
|
|
4834
|
+
const updated = reconcileReportedFields(statePath, preWriteState, precomputedUpdated, divergedFields);
|
|
3723
4835
|
const result = updated.length === 0
|
|
3724
4836
|
? { 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
4837
|
: { updated, phase: phaseNumber, plan_count: planCount };
|
|
3726
4838
|
output(result, raw, updated.length > 0 ? 'true' : 'false');
|
|
4839
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): gate on
|
|
4840
|
+
// `updated.length > 0`, NOT on `readModifyWriteStateMd`'s own write-happened
|
|
4841
|
+
// return value. The two are NOT equivalent here: readModifyWriteStateMd's
|
|
4842
|
+
// #948 no-op guard compares the transform's RAW returned string against the
|
|
4843
|
+
// RAW original file content, but `syncStateFrontmatter`'s progress-block
|
|
4844
|
+
// sync and this command's `authoritativeFm: {current_phase_name}` override
|
|
4845
|
+
// both run INSIDE the transform (via `frontmatterMod.reconstructFrontmatter`
|
|
4846
|
+
// over `existingFm`), so an unrecognized-format STATE.md — zero fields the
|
|
4847
|
+
// transition could actually apply, `updated: []`, the "transition was a
|
|
4848
|
+
// no-op" warning above — can still make the raw returned string differ
|
|
4849
|
+
// from the input (frontmatter gets synthesized: `gsd_state_version`,
|
|
4850
|
+
// `last_updated`, a zeroed `progress` block, `current_phase_name`), so the
|
|
4851
|
+
// RMW guard does NOT fire and a real write happens. That write is not a
|
|
4852
|
+
// meaningful state transition by this command's OWN reporting contract
|
|
4853
|
+
// (`updated: []`) — publishing on it would refresh state.json's
|
|
4854
|
+
// `updated_at` for a call this command itself reports did nothing.
|
|
4855
|
+
// `updated.length > 0` is the field-classification-table-backed signal
|
|
4856
|
+
// that actually answers "did plannedPhaseCore itself change anything this
|
|
4857
|
+
// caller asked it to change" — empirically verified: an unrecognized-format
|
|
4858
|
+
// STATE.md reproduces `updated: []` with a genuine (frontmatter-only) disk
|
|
4859
|
+
// write underneath it, and gating on `updated.length > 0` is what makes
|
|
4860
|
+
// this reproducer NOT publish.
|
|
4861
|
+
if (updated.length > 0)
|
|
4862
|
+
publishStateContract(cwd);
|
|
3727
4863
|
}
|
|
3728
4864
|
/**
|
|
3729
4865
|
* Bug #2630: reset STATE.md for a new milestone cycle.
|
|
@@ -3746,16 +4882,24 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
|
|
3746
4882
|
// steady-state syncStateFrontmatter post-sync.
|
|
3747
4883
|
const intent = { kind: 'milestoneSwitch', version, name: resolvedName };
|
|
3748
4884
|
const deps = { clock: clock_cjs_1.realClock, sourcePath: statePath };
|
|
4885
|
+
let switched = false;
|
|
3749
4886
|
const lockPath = acquireStateLock(statePath);
|
|
3750
4887
|
try {
|
|
3751
4888
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
3752
4889
|
const result = transitionCore(content, intent, deps);
|
|
3753
4890
|
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, result.content);
|
|
3754
4891
|
output({ switched: true, version, name: resolvedName, status: 'planning' }, raw, 'true');
|
|
4892
|
+
switched = true;
|
|
3755
4893
|
}
|
|
3756
4894
|
finally {
|
|
3757
4895
|
releaseStateLock(lockPath);
|
|
3758
4896
|
}
|
|
4897
|
+
// #3227: publish AFTER releaseStateLock — publishStateContract derives `next`
|
|
4898
|
+
// from classifyProject, which shells out to git (bounded, but up to 3 x 10s).
|
|
4899
|
+
// Holding the STATE.md lock across that would turn a millisecond hold into a
|
|
4900
|
+
// git-bound one for every concurrent GSD process.
|
|
4901
|
+
if (switched)
|
|
4902
|
+
publishStateContract(cwd);
|
|
3759
4903
|
}
|
|
3760
4904
|
/**
|
|
3761
4905
|
* Gate 1: Validate STATE.md against filesystem.
|
|
@@ -3851,10 +4995,31 @@ function readStateFrontmatterScoped(content, statePath) {
|
|
|
3851
4995
|
function stateDiagnostic(code, severity, message, advice) {
|
|
3852
4996
|
return { code, severity, message, remedy: adviseRemedy(advice) };
|
|
3853
4997
|
}
|
|
3854
|
-
function cmdStateValidate(cwd, raw) {
|
|
4998
|
+
function cmdStateValidate(cwd, raw, opts = {}) {
|
|
3855
4999
|
const statePath = planningPaths(cwd).state;
|
|
5000
|
+
// #3696: `valid: false` used to exit 0, so a CI step or git hook could not gate
|
|
5001
|
+
// on state correctness without parsing JSON — every consumer had to
|
|
5002
|
+
// re-implement the "is this actually valid" decision, which is the
|
|
5003
|
+
// duplication #3473 is about.
|
|
5004
|
+
//
|
|
5005
|
+
// The DEFAULT is deliberately unchanged. `state validate`'s exit status is
|
|
5006
|
+
// Tier-2 observable output reaching "downstream projects that cannot be
|
|
5007
|
+
// enumerated" (ADR-3180 Decision 3, Hyrum's Law), so flipping 0 -> 1 for
|
|
5008
|
+
// everyone would break every script that runs it unconditionally. `--strict`
|
|
5009
|
+
// is the opt-in the issue itself offers as the alternative.
|
|
5010
|
+
//
|
|
5011
|
+
// Routed through one emit helper rather than a trailing assignment because
|
|
5012
|
+
// three of the exit paths below (`STATE.md not found`, S001, and the four
|
|
5013
|
+
// `return` branches in the phase-drift scan) emit and return early — a fix
|
|
5014
|
+
// that only set the exit code at the end of the function would silently miss
|
|
5015
|
+
// them, which is exactly the shape of the bug being fixed.
|
|
5016
|
+
const emit = (payload) => {
|
|
5017
|
+
if (opts.strict && payload.valid !== true)
|
|
5018
|
+
process.exitCode = 1;
|
|
5019
|
+
output(payload, raw, undefined);
|
|
5020
|
+
};
|
|
3856
5021
|
if (!node_fs_1.default.existsSync(statePath)) {
|
|
3857
|
-
|
|
5022
|
+
emit({ error: 'STATE.md not found' });
|
|
3858
5023
|
return;
|
|
3859
5024
|
}
|
|
3860
5025
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
@@ -3867,10 +5032,10 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3867
5032
|
// unconditionally and returned immediately, matching every other
|
|
3868
5033
|
// error-class code, not a mere warning). Message reused verbatim from
|
|
3869
5034
|
// `textEncodingError`, not paraphrased.
|
|
3870
|
-
|
|
5035
|
+
emit({
|
|
3871
5036
|
valid: false,
|
|
3872
5037
|
warnings: [stateDiagnostic('S001', SEVERITY.ERROR, encErr, 'Re-save STATE.md as UTF-8 text with the embedded NUL byte(s) removed')],
|
|
3873
|
-
}
|
|
5038
|
+
});
|
|
3874
5039
|
return;
|
|
3875
5040
|
}
|
|
3876
5041
|
const warnings = [];
|
|
@@ -3888,7 +5053,7 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3888
5053
|
const phasesDir = planningPaths(cwd).phases;
|
|
3889
5054
|
if (currentPhase === null) {
|
|
3890
5055
|
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
|
-
|
|
5056
|
+
emit({ valid: false, warnings, scope });
|
|
3892
5057
|
return;
|
|
3893
5058
|
}
|
|
3894
5059
|
const selectedPhaseKey = phaseKeyFromToken(currentPhase);
|
|
@@ -3897,23 +5062,41 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3897
5062
|
}
|
|
3898
5063
|
if (!node_fs_1.default.existsSync(phasesDir)) {
|
|
3899
5064
|
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
|
-
|
|
5065
|
+
emit({ valid: false, warnings, scope });
|
|
3901
5066
|
return;
|
|
3902
5067
|
}
|
|
5068
|
+
// #612: #3208 replaced this lookup's `startsWith` prefix test with the
|
|
5069
|
+
// canonical key comparison — which is the right surface, and is exactly why it
|
|
5070
|
+
// now needs the convention. `phaseKeyFromDir` refuses to read a bracket
|
|
5071
|
+
// directory without an explicit signal (a bracket dir is string-
|
|
5072
|
+
// indistinguishable from the legacy letter-prefixed-decimal family, ADR-2121),
|
|
5073
|
+
// so un-threaded it returns the WHOLE dir name as the key —
|
|
5074
|
+
// `GSD.02-05-delta` -> `GSD.02-5-DELTA` — while `selectedPhaseKey` is the bare
|
|
5075
|
+
// `05` that `parsePhaseFromProse` yields. The two sides of one comparison were
|
|
5076
|
+
// derived under different conventions, which is #2562's defect class and the
|
|
5077
|
+
// thing this file's other three `phaseKeyFromDir` call sites already thread
|
|
5078
|
+
// against. Un-threaded, a bracket repo whose phase directory plainly exists
|
|
5079
|
+
// reports `no phase directory matches phase 05` and `valid: false` — a
|
|
5080
|
+
// wrong-and-confident answer on precisely the repos this convention supports.
|
|
5081
|
+
// Resolved here rather than reusing a caller's value because cmdStateValidate
|
|
5082
|
+
// has no other convention-dependent read. Non-bracket conventions (null,
|
|
5083
|
+
// 'milestone-prefixed', unresolvable) are byte-identical to the un-threaded
|
|
5084
|
+
// call by construction: `extractPhaseToken` branches only on `=== 'bracket'`.
|
|
5085
|
+
const validateConvention = resolvePhaseIdConvention(cwd);
|
|
3903
5086
|
let phaseDirPath;
|
|
3904
5087
|
try {
|
|
3905
5088
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
3906
|
-
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey);
|
|
5089
|
+
const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey);
|
|
3907
5090
|
if (!phaseDir) {
|
|
3908
5091
|
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
|
-
|
|
5092
|
+
emit({ valid: false, warnings, scope });
|
|
3910
5093
|
return;
|
|
3911
5094
|
}
|
|
3912
5095
|
phaseDirPath = node_path_1.default.join(phasesDir, phaseDir.name);
|
|
3913
5096
|
}
|
|
3914
5097
|
catch {
|
|
3915
5098
|
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
|
-
|
|
5099
|
+
emit({ valid: false, warnings, scope });
|
|
3917
5100
|
return;
|
|
3918
5101
|
}
|
|
3919
5102
|
try {
|
|
@@ -3981,8 +5164,56 @@ function cmdStateValidate(cwd, raw) {
|
|
|
3981
5164
|
catch {
|
|
3982
5165
|
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
5166
|
}
|
|
5167
|
+
// #3696 — the `last_activity` invariant. Three readers consumed this field
|
|
5168
|
+
// and none of them checked it, so a value no reader can parse validated as
|
|
5169
|
+
// `{valid:true, warnings:[], scope:'complete'}`: the scan ran to completion
|
|
5170
|
+
// and simply never looked. Read through the same owner every other field here
|
|
5171
|
+
// uses (ADR-3180 §7.7) — never a private `stateExtractField` call, which is
|
|
5172
|
+
// what `scripts/lint-state-field-drift.cjs` counts.
|
|
5173
|
+
const lastActivity = (0, state_document_cjs_1.stateFieldValue)(fm, body, 'last_activity', 'Last activity').value;
|
|
5174
|
+
// NOT FILLED IN IS NOT DRIFT, and that covers three shapes, not one: absent,
|
|
5175
|
+
// blank, and the shipped template's `[YYYY-MM-DD] — [What happened]`
|
|
5176
|
+
// placeholder. Only a value a writer actually supplied can be wrong.
|
|
5177
|
+
if (!(0, state_document_cjs_1.isUnfilledFieldValue)(lastActivity)) {
|
|
5178
|
+
// Calendar validity, not merely `\d{4}-\d{2}-\d{2}` shape: smart-entry's
|
|
5179
|
+
// reader rejects 2026-02-30 via isRealCalendarDate (ADR-227 — validate shape
|
|
5180
|
+
// AND value). Accepting it here would leave the two surfaces disagreeing
|
|
5181
|
+
// about whether the file is usable, which is the complaint #3696 opens with.
|
|
5182
|
+
//
|
|
5183
|
+
// Review round 2: this asserts the LEADING date token, not
|
|
5184
|
+
// `parseProseLastActivityField`'s fully-anchored `date — description`
|
|
5185
|
+
// grammar. That grammar is stricter than any real reader, and routing the
|
|
5186
|
+
// check through it made S008 fire on values smart-entry parses fine (e.g.
|
|
5187
|
+
// `2026-08-24 Shipped feature X`, no dash separator) — the same
|
|
5188
|
+
// two-surfaces-disagree defect, pointing the other way. See
|
|
5189
|
+
// `leadingCalendarDate`.
|
|
5190
|
+
if ((0, state_document_cjs_1.leadingCalendarDate)(lastActivity) === null) {
|
|
5191
|
+
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"'));
|
|
5192
|
+
}
|
|
5193
|
+
// The attached half of #3696: `templates/state.md` prescribes a single-line
|
|
5194
|
+
// field, but writers emit descriptions long enough to wrap, and
|
|
5195
|
+
// `stateExtractField`'s newline-excluding `(.+)` drops the remainder with no
|
|
5196
|
+
// diagnostic. The DOCUMENT is what violates the template here, so this
|
|
5197
|
+
// reports the violation rather than teaching the reader a multi-line grammar
|
|
5198
|
+
// the template does not sanction (ADR-3180 §7.7 Rejected #1 forbids widening
|
|
5199
|
+
// stateExtractField, which has 20 callers and a CRITICAL blast radius).
|
|
5200
|
+
//
|
|
5201
|
+
// Scan the body ONLY when the body is what was actually read. The ladder
|
|
5202
|
+
// prefers the frontmatter scalar, so a document carrying a clean
|
|
5203
|
+
// `last_activity:` in frontmatter AND a stale, wrapped `Last activity:` line
|
|
5204
|
+
// in the body would otherwise report S009 — and exit 1 under `--strict` —
|
|
5205
|
+
// over a remainder that no reader consumes and whose field is entirely
|
|
5206
|
+
// valid. Asking the owner with an EMPTY body isolates the frontmatter rung
|
|
5207
|
+
// without re-deriving the ladder here (which is what
|
|
5208
|
+
// `scripts/lint-state-field-drift.cjs` counts).
|
|
5209
|
+
const fromFrontmatter = (0, state_document_cjs_1.stateFieldValue)(fm, '', 'last_activity', 'Last activity').value;
|
|
5210
|
+
const dropped = fromFrontmatter !== null ? null : (0, state_document_cjs_1.stateFieldContinuation)(body, 'Last activity');
|
|
5211
|
+
if (dropped !== null) {
|
|
5212
|
+
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'));
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
3984
5215
|
const valid = warnings.length === 0;
|
|
3985
|
-
|
|
5216
|
+
emit({ valid, warnings, scope });
|
|
3986
5217
|
}
|
|
3987
5218
|
/**
|
|
3988
5219
|
* Gate 2: Sync STATE.md from filesystem ground truth.
|
|
@@ -3997,6 +5228,19 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
3997
5228
|
}
|
|
3998
5229
|
const verify = options && options.verify;
|
|
3999
5230
|
const content = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
5231
|
+
// ADR-3473 §8.5 (#3881): `state sync` is on ADR-3408 §8.3's closed
|
|
5232
|
+
// sanctioned-regenerate list — "the body wins" — and `syncStateFrontmatter`
|
|
5233
|
+
// (below, via `writeStateMd`'s `sanctionedPermanentEmptyFallback`) is
|
|
5234
|
+
// therefore CORRECT to overwrite even an unparseable existing frontmatter
|
|
5235
|
+
// block (git merge-conflict markers, malformed YAML). What was missing was
|
|
5236
|
+
// disclosure: a derived conclusion (`synced: true`) must not be reported as
|
|
5237
|
+
// authoritative when the derivation dropped input it could not resolve
|
|
5238
|
+
// (§8.5) — silently destroying the only copy of an unreadable block with no
|
|
5239
|
+
// signal is "failure is a value" (§8.4) violated. Computed once, up front,
|
|
5240
|
+
// from the pre-write snapshot so both the `--verify` (dry-run) and the real
|
|
5241
|
+
// write branch can surface it identically.
|
|
5242
|
+
const existingSyncFm = extractFrontmatter(content, statePath);
|
|
5243
|
+
const syncFrontmatterWasUnparseable = isUnparseableFrontmatter(existingSyncFm);
|
|
4000
5244
|
const changes = [];
|
|
4001
5245
|
let modified = content;
|
|
4002
5246
|
const phasesDir = planningPaths(cwd).phases;
|
|
@@ -4011,22 +5255,51 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4011
5255
|
let syncRoadmapScope = null;
|
|
4012
5256
|
let syncRoadmapRaw = null;
|
|
4013
5257
|
let syncRetiredPhaseNums = new Set();
|
|
5258
|
+
const syncConvention = resolvePhaseIdConvention(cwd);
|
|
4014
5259
|
try {
|
|
4015
5260
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
|
|
4016
5261
|
if (roadmapRaw !== null) {
|
|
4017
5262
|
syncRoadmapRaw = roadmapRaw;
|
|
4018
5263
|
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
|
4019
|
-
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
|
|
5264
|
+
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention);
|
|
4020
5265
|
}
|
|
4021
5266
|
}
|
|
4022
5267
|
catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
|
5268
|
+
// #2761 Major 1 (round-2 adversarial review): this disk scan fed
|
|
5269
|
+
// totalDiskPlans/totalDiskSummaries/diskCompletedPhases/syncTotalPhases
|
|
5270
|
+
// below UNFILTERED — no milestone-window filter, unlike
|
|
5271
|
+
// buildStateFrontmatter's identical-purpose scan a few hundred lines above
|
|
5272
|
+
// (`:1698`). One command (`state sync`) therefore wrote TWO contradictory
|
|
5273
|
+
// numbers into the same STATE.md: frontmatter total_phases/completed_phases
|
|
5274
|
+
// milestone-scoped correctly (via the READ derivation), body Progress
|
|
5275
|
+
// percent computed from the whole disk. On the ADR-canonical version-less
|
|
5276
|
+
// bracket fixture (4 dirs, 3 complete; asserted milestone = 2 phases, both
|
|
5277
|
+
// complete): body wrote 75% where 100% is true (repro3).
|
|
5278
|
+
//
|
|
5279
|
+
// GATED on `syncConvention === 'bracket'` — an unconditional filter would
|
|
5280
|
+
// ALSO move LEGACY sync percents, since the milestone-scoping-vs-whole-disk
|
|
5281
|
+
// divergence this fixes is engine-wide, not bracket-specific; the gate
|
|
5282
|
+
// keeps legacy byte-identical, which is the binding constraint here. This
|
|
5283
|
+
// is a DEVIATION from an earlier "mirror :1698 unconditionally" phrasing —
|
|
5284
|
+
// deliberate, not an oversight: legacy repos are DOWNSTREAM of a Progress
|
|
5285
|
+
// percent that has read this way for a long time, and moving it as a side
|
|
5286
|
+
// effect of a bracket-only PR is out of this fix's scope.
|
|
5287
|
+
// Upstream #3185 made `listMilestonePhaseDirs` the sole phase-directory
|
|
5288
|
+
// enumeration owner; it delegates window membership to
|
|
5289
|
+
// getMilestonePhaseFilter. Cache that owner's bracket result as a set and
|
|
5290
|
+
// compose it with this scan, rather than restoring the retired direct
|
|
5291
|
+
// parser dependency. Legacy retains this scan's prior pass-all behavior.
|
|
5292
|
+
const syncMilestonePhaseDirs = syncConvention === 'bracket'
|
|
5293
|
+
? new Set(listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: syncConvention }).value)
|
|
5294
|
+
: null;
|
|
4023
5295
|
// Scan all phases
|
|
4024
5296
|
let entries;
|
|
4025
5297
|
try {
|
|
4026
5298
|
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
4027
5299
|
.filter(e => e.isDirectory())
|
|
4028
5300
|
.map(e => e.name)
|
|
4029
|
-
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
|
|
5301
|
+
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention))))
|
|
5302
|
+
.filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name))
|
|
4030
5303
|
.sort();
|
|
4031
5304
|
}
|
|
4032
5305
|
catch {
|
|
@@ -4074,28 +5347,18 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4074
5347
|
}
|
|
4075
5348
|
}
|
|
4076
5349
|
// Determine total phases from ROADMAP (may be larger than realized disk dirs).
|
|
4077
|
-
//
|
|
4078
|
-
//
|
|
4079
|
-
//
|
|
4080
|
-
//
|
|
5350
|
+
// #612 round-4: shares countRoadmapPhaseHeadings with buildStateFrontmatter
|
|
5351
|
+
// (defined just above extractRetiredPhaseNumbers) so both report
|
|
5352
|
+
// consistent totals off the SAME implementation, not two independently
|
|
5353
|
+
// maintained copies (#3242 Bug B).
|
|
5354
|
+
// #612 round-5: bracket sync enables the same bare-token 999 exclusion as
|
|
5355
|
+
// the read path and getMilestonePhaseFilter, preventing frontmatter/body
|
|
5356
|
+
// disagreement. Non-bracket conventions still pass false, preserving the
|
|
5357
|
+
// pre-existing legacy sync behavior while #3185 remains the read-path owner.
|
|
4081
5358
|
let syncTotalPhases = null;
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
|
|
4086
|
-
let m;
|
|
4087
|
-
while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
|
|
4088
|
-
// Only count tokens that contain at least one digit — excludes
|
|
4089
|
-
// pure-word section headings (Overview, Details) while keeping
|
|
4090
|
-
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
|
4091
|
-
if (!/\d/.test(m[1]))
|
|
4092
|
-
continue;
|
|
4093
|
-
// #1514: retired/folded phases are struck through; exclude from total.
|
|
4094
|
-
if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1])))
|
|
4095
|
-
continue;
|
|
4096
|
-
roadmapPhaseCount++;
|
|
4097
|
-
}
|
|
4098
|
-
}
|
|
5359
|
+
const roadmapPhaseCount = syncRoadmapScope !== null
|
|
5360
|
+
? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket')
|
|
5361
|
+
: 0;
|
|
4099
5362
|
if (roadmapPhaseCount > 0) {
|
|
4100
5363
|
syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
|
|
4101
5364
|
}
|
|
@@ -4115,8 +5378,9 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4115
5378
|
if (versionStr !== null && syncRoadmapRaw !== null) {
|
|
4116
5379
|
// #3184: routed through the single owner (roadmap-parser.cjs) instead of
|
|
4117
5380
|
// a hand-rolled, unbounded-substring re-derivation — see the identical
|
|
4118
|
-
// fix in buildStateFrontmatter above.
|
|
4119
|
-
|
|
5381
|
+
// fix in buildStateFrontmatter above. #612 composes its gated bracket
|
|
5382
|
+
// extension on top inside isMilestoneBounded.
|
|
5383
|
+
milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention);
|
|
4120
5384
|
}
|
|
4121
5385
|
let percent = null;
|
|
4122
5386
|
if (!milestoneBounded) {
|
|
@@ -4134,6 +5398,20 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4134
5398
|
// it here (discarding `.value`, which duplicates `entries`'s own
|
|
4135
5399
|
// retired-phase-filtered listing) gets the real scope without changing
|
|
4136
5400
|
// the disk-scan totals computed above.
|
|
5401
|
+
//
|
|
5402
|
+
// #2761 (round-11 M2 follow-up): deliberately NOT threading
|
|
5403
|
+
// `phaseIdConvention` here, unlike the other call sites this same PR
|
|
5404
|
+
// converts. Only `.scope` is consumed (the `.value` directory list is
|
|
5405
|
+
// thrown away), and inside `getMilestonePhaseFilter` `scope` is computed
|
|
5406
|
+
// from `extractCurrentMilestoneScoped`/`classifyMilestoneWindow` BEFORE
|
|
5407
|
+
// `headingConvention` is resolved — `phaseIdConvention` only reaches the
|
|
5408
|
+
// heading/dir MEMBERSHIP scan (`scanMilestonePhaseIds`, `isDirInMilestone`)
|
|
5409
|
+
// that produces `.value`, never the scope discriminator itself. So the
|
|
5410
|
+
// `undefined` default here (lazy resolve-from-config) and an explicitly
|
|
5411
|
+
// threaded `syncConvention` would compute the identical `scope` either
|
|
5412
|
+
// way — there is no silent-inherit exposure to close at this site, only
|
|
5413
|
+
// at sites (milestone.cts, cmdStateUpdateProgress above) that also
|
|
5414
|
+
// consume `.value`.
|
|
4137
5415
|
const syncScope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope;
|
|
4138
5416
|
if (syncScope !== SCOPE.COMPLETE) {
|
|
4139
5417
|
changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`);
|
|
@@ -4147,12 +5425,30 @@ function cmdStateSync(cwd, options, raw) {
|
|
|
4147
5425
|
modified = syncResult.content;
|
|
4148
5426
|
const coreChanges = syncResult.data?.changes ?? [];
|
|
4149
5427
|
changes.push(...coreChanges);
|
|
5428
|
+
// #3881 (ADR-3473 §8.5): only warn when a write will actually regenerate the
|
|
5429
|
+
// frontmatter — if nothing changed this run, the unparseable block (if any)
|
|
5430
|
+
// was never touched, so there is nothing to disclose. Mirrors the exact
|
|
5431
|
+
// condition the write branch below uses to decide whether to write at all.
|
|
5432
|
+
const syncWillWrite = changes.length > 0 || modified !== content;
|
|
5433
|
+
if (syncWillWrite && syncFrontmatterWasUnparseable) {
|
|
5434
|
+
const unparseableWarning = `gsd: warning — STATE.md's existing frontmatter could not be parsed (malformed YAML, or ` +
|
|
5435
|
+
`unresolved content such as git merge-conflict markers) and was regenerated from the body; ` +
|
|
5436
|
+
`any content in the old frontmatter block — including merge-conflict markers — has been ` +
|
|
5437
|
+
`replaced. (#3881)`;
|
|
5438
|
+
process.stderr.write(`${unparseableWarning}\n`);
|
|
5439
|
+
changes.push(unparseableWarning);
|
|
5440
|
+
}
|
|
4150
5441
|
if (verify) {
|
|
4151
5442
|
output({ synced: false, changes, dry_run: true }, raw, undefined);
|
|
4152
5443
|
return;
|
|
4153
5444
|
}
|
|
4154
|
-
if (
|
|
4155
|
-
|
|
5445
|
+
if (syncWillWrite) {
|
|
5446
|
+
// ADR-3473 §8.6: `rebuild()` is the typed expression of #905's contract —
|
|
5447
|
+
// `state sync` exists to let the body win, so preservation must NOT run,
|
|
5448
|
+
// and the snapshot is carried anyway because §8.7's reporting needs it.
|
|
5449
|
+
writeStateMd(statePath, modified, stateTransitionMod.rebuildStateTransaction({
|
|
5450
|
+
snapshot: extractFrontmatter(content, statePath),
|
|
5451
|
+
}), cwd);
|
|
4156
5452
|
}
|
|
4157
5453
|
output({ synced: true, changes, dry_run: false }, raw, undefined);
|
|
4158
5454
|
}
|
|
@@ -4494,16 +5790,38 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
4494
5790
|
// flat `string[]` (the command's OUTPUT CONTRACT — unchanged) happens once
|
|
4495
5791
|
// below, right before `output()`.
|
|
4496
5792
|
const updated = [];
|
|
4497
|
-
let preSyncContent = '';
|
|
4498
5793
|
const divergedFields = [];
|
|
4499
|
-
|
|
5794
|
+
// ADR-3473 §8.7 (#3872): caller-allocated out-param, filled with the
|
|
5795
|
+
// transaction's own pre-write snapshot + body by `applyPostSyncPreservation`.
|
|
5796
|
+
const preWriteState = {};
|
|
5797
|
+
// #3835: complete-phase unconditionally rewrites the body `Phase:` line to
|
|
5798
|
+
// `N — COMPLETE` below. That defeats current_phase_name's
|
|
5799
|
+
// preserve-when-unchanged delta rule the same way #3834's no-`--name`
|
|
5800
|
+
// planned-phase write does — pre/post body-source disagree BY CONSTRUCTION
|
|
5801
|
+
// (this write is what changed the source line), so the post-sync
|
|
5802
|
+
// re-derivation harvests nothing from "COMPLETE" and the curated key is
|
|
5803
|
+
// dropped entirely rather than preserved. The write site already documents
|
|
5804
|
+
// "an absent name does NOT clear an existing curated value" for the body
|
|
5805
|
+
// (`Current Phase Name` section below) — this reasserts the same rule for
|
|
5806
|
+
// the frontmatter key, mirroring cmdStatePlannedPhase's fix.
|
|
5807
|
+
const rmwOptions = { divergedFields, preWriteState };
|
|
5808
|
+
const wrote = readModifyWriteStateMd(statePath, (content) => {
|
|
4500
5809
|
const currentPhase = resolvedPhase;
|
|
4501
5810
|
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
|
4502
5811
|
// cannot shadow the body Status field (pipe-table or inline).
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
5812
|
+
//
|
|
5813
|
+
// ADR-3473 §8.1 (#3881 review, finding 5): previously this block hand-reimplemented
|
|
5814
|
+
// the isUnparseableFrontmatter/rawFrontmatterPrefix shape inline instead of using the
|
|
5815
|
+
// canonical helper — the sixth copy of a block already duplicated 5x in
|
|
5816
|
+
// state-transition.cts. Routed through the shared `beginFrontmatterReassembly` so this
|
|
5817
|
+
// module can never drift from the frontmatter-preservation contract state-transition.cts
|
|
5818
|
+
// enforces everywhere else.
|
|
5819
|
+
const { existingFm, body: initialBody, reassemble } = stateTransitionMod.beginFrontmatterReassembly(content, statePath);
|
|
5820
|
+
let body = initialBody;
|
|
5821
|
+
const curatedPhaseName = existingFm['current_phase_name'];
|
|
5822
|
+
if (typeof curatedPhaseName === 'string' && curatedPhaseName.trim().length > 0) {
|
|
5823
|
+
rmwOptions.authoritativeFm = { current_phase_name: curatedPhaseName };
|
|
5824
|
+
}
|
|
4507
5825
|
// Update Status field (body only — #1255)
|
|
4508
5826
|
const statusValue = `Phase ${currentPhase} complete`;
|
|
4509
5827
|
let result = (0, state_document_cjs_1.stateReplaceField)(body, 'Status', statusValue);
|
|
@@ -4584,10 +5902,8 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
4584
5902
|
updated.push({ kind: 'section', name: 'Current Position' });
|
|
4585
5903
|
}
|
|
4586
5904
|
}
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
return out;
|
|
4590
|
-
}, cwd, { divergedFields });
|
|
5905
|
+
return reassemble(body);
|
|
5906
|
+
}, cwd, rmwOptions);
|
|
4591
5907
|
// ADR-3408 §8.4 (D4): traced for this phase (design doc: "not traced in
|
|
4592
5908
|
// the analysis pass"). Unlike the transitionCore-based commands, this
|
|
4593
5909
|
// adapter's `updated` mixes FIELD entries (Status, Last Activity, Last
|
|
@@ -4603,8 +5919,17 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) {
|
|
|
4603
5919
|
// string-matching a name against a Set.
|
|
4604
5920
|
const sectionEntries = updated.filter((e) => e.kind === 'section').map((e) => e.name);
|
|
4605
5921
|
const fieldEntries = updated.filter((e) => e.kind === 'field').map((e) => e.name);
|
|
4606
|
-
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath,
|
|
5922
|
+
const reconciled = [...sectionEntries, ...reconcileReportedFields(statePath, preWriteState, fieldEntries, divergedFields)];
|
|
4607
5923
|
output({ updated: reconciled, phase: resolvedPhase }, raw, reconciled.length > 0 ? 'true' : 'false');
|
|
5924
|
+
// #3227: gate on `wrote` (readModifyWriteStateMd's own return value), not
|
|
5925
|
+
// `reconciled.length > 0` — a re-run of complete-phase against a phase
|
|
5926
|
+
// that is ALREADY marked complete (same status/date/Current Position
|
|
5927
|
+
// values already on disk) still has `stateReplaceField` report a match for
|
|
5928
|
+
// every field it looks up, so `reconciled` is non-empty even though the
|
|
5929
|
+
// #948 no-op guard skipped the write. Same reasoning as
|
|
5930
|
+
// cmdStateBeginPhase/cmdStatePlannedPhase/cmdStateAdvancePlan above.
|
|
5931
|
+
if (wrote)
|
|
5932
|
+
publishStateContract(cwd);
|
|
4608
5933
|
}
|
|
4609
5934
|
module.exports = {
|
|
4610
5935
|
stateExtractField: state_document_cjs_1.stateExtractField,
|
|
@@ -4659,6 +5984,23 @@ module.exports = {
|
|
|
4659
5984
|
// FIELD_CLASSIFICATION, exposed so a parity test can pin that every
|
|
4660
5985
|
// `preserve-when-unchanged` row has a label here.
|
|
4661
5986
|
_FRONTMATTER_KEY_TO_BODY_LABEL: FRONTMATTER_KEY_TO_BODY_LABEL,
|
|
5987
|
+
// Test seam (ADR-3473 §8.7, #3872): the transaction diff and its pure
|
|
5988
|
+
// building blocks, exposed so the ~15 boundary/hostile/property rows in
|
|
5989
|
+
// the test matrix (dotted-path resolution, prototype-pollution safety,
|
|
5990
|
+
// string/number representation insensitivity, the provenance exclusion)
|
|
5991
|
+
// can be driven directly with fabricated snapshot/persisted objects
|
|
5992
|
+
// instead of round-tripping every case through a full RMW write.
|
|
5993
|
+
_reconcileReportedFields: reconcileReportedFields,
|
|
5994
|
+
_computeChangedFrontmatterFields: computeChangedFrontmatterFields,
|
|
5995
|
+
_resolveFrontmatterPath: resolveFrontmatterPath,
|
|
5996
|
+
_stateFieldValuesDiffer: stateFieldValuesDiffer,
|
|
5997
|
+
_STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION,
|
|
5998
|
+
// Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not
|
|
5999
|
+
// otherwise reachable from outside this module. Exposed so a test can drive
|
|
6000
|
+
// the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only
|
|
6001
|
+
// pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
|
|
6002
|
+
// itself.
|
|
6003
|
+
_bodyLabelFor: bodyLabelFor,
|
|
4662
6004
|
// Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
|
|
4663
6005
|
// steal decision is exercised without real pids. Mirrors capability-lock.cts.
|
|
4664
6006
|
_setLockProbes(probes) {
|