@opengsd/gsd-core 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +12 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +22 -14
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +5 -3
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +23 -2
- package/agents/gsd-plan-checker.md +50 -53
- package/agents/gsd-planner.md +50 -50
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +13 -4
- package/bin/install.js +1448 -1103
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +9 -5
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1035 -138
- package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
- package/gsd-core/bin/lib/api-coverage.cjs +33 -14
- package/gsd-core/bin/lib/artifacts.cjs +5 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +1026 -268
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +845 -130
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +700 -40
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +216 -42
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +1172 -108
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
- package/gsd-core/bin/lib/config-loader.cjs +187 -23
- package/gsd-core/bin/lib/config.cjs +102 -3
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +208 -33
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +899 -229
- package/gsd-core/bin/lib/gap-checker.cjs +95 -10
- package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +439 -168
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +811 -259
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
- package/gsd-core/bin/lib/install-profiles.cjs +212 -61
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +170 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +183 -22
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +842 -73
- package/gsd-core/bin/lib/model-catalog.cjs +232 -16
- package/gsd-core/bin/lib/model-resolver.cjs +193 -68
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
- package/gsd-core/bin/lib/phase-id.cjs +514 -40
- package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
- package/gsd-core/bin/lib/phase-locator.cjs +262 -34
- package/gsd-core/bin/lib/phase.cjs +1038 -214
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +98 -3
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
- package/gsd-core/bin/lib/probe-core.cjs +5 -2
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
- package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +442 -96
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
- package/gsd-core/bin/lib/smart-entry.cjs +133 -23
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +52 -19
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +338 -8
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +846 -176
- package/gsd-core/bin/lib/state.cjs +2589 -369
- package/gsd-core/bin/lib/surface.cjs +33 -11
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
- package/gsd-core/bin/lib/uat.cjs +1761 -167
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
- package/gsd-core/bin/lib/unusable-input.cjs +37 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +75 -0
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +272 -9
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +453 -918
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +44 -26
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +39 -21
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +4 -6
- package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +17 -11
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +5 -4
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +61 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +44 -13
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +5 -5
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -17
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +5 -4
- package/gsd-core/templates/state.md +11 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +13 -20
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +11 -27
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +64 -5
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
- package/gsd-core/workflows/code-review-fix.md +38 -11
- package/gsd-core/workflows/code-review.md +159 -52
- package/gsd-core/workflows/complete-milestone.md +151 -23
- package/gsd-core/workflows/debug.md +12 -8
- package/gsd-core/workflows/diagnose-issues.md +47 -15
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +27 -2
- package/gsd-core/workflows/eval-review.md +7 -14
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +72 -100
- package/gsd-core/workflows/execute-plan.md +52 -15
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +76 -10
- package/gsd-core/workflows/import.md +18 -15
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +49 -16
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +8 -5
- package/gsd-core/workflows/new-milestone.md +22 -29
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +26 -40
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +14 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +162 -59
- package/gsd-core/workflows/plan-review-convergence.md +96 -11
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +61 -18
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
- package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
- package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +55 -44
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +219 -20
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +69 -7
- package/gsd-core/workflows/ship.md +116 -50
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +53 -13
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +64 -9
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +49 -14
- package/gsd-core/workflows/ui-phase.md +15 -21
- package/gsd-core/workflows/ui-review.md +8 -12
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +7 -11
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +66 -25
- package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
- package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +37 -27
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +55 -32
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +231 -24
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +162 -46
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +158 -30
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +2 -3
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +83 -3
- package/hooks/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +37 -27
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +55 -32
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +231 -24
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +162 -46
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +28 -11
- package/pi/gsd.cjs +19 -5
- package/scripts/base64-scan.sh +74 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +5 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +64 -14
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +11 -21
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +138 -25
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +308 -0
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +492 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +471 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +488 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +950 -0
- package/scripts/lint-test-file-count.allowlist.json +137 -8
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +830 -0
- package/scripts/lint-vendored-deps.cjs +297 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +100 -14
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +24 -7
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +9 -5
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -107
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
* file I/O, and the disk-scan wrap it.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
|
|
18
|
+
exports.STATE_MD_SECTIONS = exports.FRONTMATTER_BODY_SOURCE = exports.FIELD_CLASSIFICATION = void 0;
|
|
19
|
+
exports.beginFrontmatterReassembly = beginFrontmatterReassembly;
|
|
20
|
+
exports.getFrontmatterBodySource = getFrontmatterBodySource;
|
|
21
|
+
exports.frontmatterKeyForBodyField = frontmatterKeyForBodyField;
|
|
19
22
|
exports.getFieldClassification = getFieldClassification;
|
|
23
|
+
exports.getPreserveWhenUnchangedFields = getPreserveWhenUnchangedFields;
|
|
24
|
+
exports.openStateTransaction = openStateTransaction;
|
|
25
|
+
exports.rebuildStateTransaction = rebuildStateTransaction;
|
|
26
|
+
exports.applyPreserveWhenUnchanged = applyPreserveWhenUnchanged;
|
|
20
27
|
exports.applyStatePreservation = applyStatePreservation;
|
|
21
28
|
exports.transitionCore = transitionCore;
|
|
22
29
|
exports.sliceCurrentPositionSection = sliceCurrentPositionSection;
|
|
@@ -26,10 +33,53 @@ const state_document_cjs_1 = require("./state-document.cjs");
|
|
|
26
33
|
const state_document_cjs_2 = require("./state-document.cjs");
|
|
27
34
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
28
35
|
const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
|
|
36
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
29
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
|
-
const
|
|
31
|
-
const {
|
|
32
|
-
const {
|
|
38
|
+
const stateMdSchemaMod = require("./state-md-schema.cjs");
|
|
39
|
+
const { STATE_FIELD_SCHEMA } = stateMdSchemaMod;
|
|
40
|
+
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
|
|
41
|
+
/**
|
|
42
|
+
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
|
|
43
|
+
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
|
|
44
|
+
* frontmatter-fenced region exists but failed to parse (malformed YAML, or a
|
|
45
|
+
* refused anchor/alias/merge key)? A plain `Object.keys(existingFm).length >
|
|
46
|
+
* 0` check cannot distinguish that case from "no frontmatter block at all" —
|
|
47
|
+
* both parse to `{}` — so every `hasFrontmatter`-gated reassemble below would
|
|
48
|
+
* silently drop the raw frontmatter block on the next write. The marker is a
|
|
49
|
+
* non-enumerable-to-Object.keys Symbol key, so this check is additive and
|
|
50
|
+
* never fires for the genuinely-empty case.
|
|
51
|
+
*/
|
|
52
|
+
function isUnparseableFrontmatter(existingFm) {
|
|
53
|
+
return existingFm[FRONTMATTER_UNPARSEABLE] === true;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* ADR-3473 §8.1 (#3881): the exact bytes `stripFrontmatter` removed from the
|
|
57
|
+
* front of `content` to produce `strippedBody` — i.e. `content`'s raw
|
|
58
|
+
* frontmatter-fenced prefix, verbatim, whether or not it parsed. Reassembling
|
|
59
|
+
* with this prefix (instead of dropping it under `hasFrontmatter === false`)
|
|
60
|
+
* is what preserves an UNPARSEABLE frontmatter block across a write; it is a
|
|
61
|
+
* no-op difference from `content` itself when `strippedBody === content`
|
|
62
|
+
* (nothing was stripped).
|
|
63
|
+
*/
|
|
64
|
+
function rawFrontmatterPrefix(content, strippedBody) {
|
|
65
|
+
return content.slice(0, content.length - strippedBody.length);
|
|
66
|
+
}
|
|
67
|
+
function beginFrontmatterReassembly(content, sourcePath) {
|
|
68
|
+
const existingFm = extractFrontmatter(content, sourcePath);
|
|
69
|
+
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
70
|
+
const body = stripFrontmatter(content);
|
|
71
|
+
// ADR-3473 §8.1 (#3881): computed from the ORIGINAL content/body pair, before any caller
|
|
72
|
+
// reassigns `body` further — the captured prefix is always the exact bytes stripped from
|
|
73
|
+
// the ORIGINAL content, regardless of what the caller does with `body` afterward.
|
|
74
|
+
const fmPrefix = rawFrontmatterPrefix(content, body);
|
|
75
|
+
const unparseableFm = isUnparseableFrontmatter(existingFm);
|
|
76
|
+
const reassemble = (b) => hasFrontmatter
|
|
77
|
+
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
78
|
+
: unparseableFm
|
|
79
|
+
? `${fmPrefix}${b}`
|
|
80
|
+
: b;
|
|
81
|
+
return { existingFm, hasFrontmatter, body, fmPrefix, unparseableFm, reassemble };
|
|
82
|
+
}
|
|
33
83
|
// Stop predicate for section-body slicing: a level-2+ heading ends the section.
|
|
34
84
|
const STOP_H2_PLUS = (lv) => lv >= 2;
|
|
35
85
|
/**
|
|
@@ -46,32 +96,150 @@ const STOP_H2_PLUS = (lv) => lv >= 2;
|
|
|
46
96
|
* (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
|
|
47
97
|
* function). Use `getFieldClassification()` for lookups.
|
|
48
98
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
/**
|
|
100
|
+
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
|
101
|
+
* (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical
|
|
102
|
+
* to the pre-#3873 literal table — same 19 keys, same key ORDER (walks
|
|
103
|
+
* `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order
|
|
104
|
+
* comment for why this is the one projection allowed to do that), same
|
|
105
|
+
* per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that
|
|
106
|
+
* key order, `guard`/`mergeStrategy` present only when the schema row carries
|
|
107
|
+
* them — never as an `undefined` own-property), same frozen null-prototype
|
|
108
|
+
* container. Pinned by `tests/state-transition.test.cjs`'s
|
|
109
|
+
* `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is
|
|
110
|
+
* today's literal copied VERBATIM into the test (never re-derived from this
|
|
111
|
+
* schema — see that test's own docstring on why a self-referential parity
|
|
112
|
+
* test proves nothing).
|
|
113
|
+
*/
|
|
114
|
+
exports.FIELD_CLASSIFICATION = Object.freeze(Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => {
|
|
115
|
+
const row = STATE_FIELD_SCHEMA[key];
|
|
116
|
+
const projected = { source: row.source, preservation: row.preservation };
|
|
117
|
+
if (row.guard !== undefined)
|
|
118
|
+
projected.guard = row.guard;
|
|
119
|
+
if (row.mergeStrategy !== undefined)
|
|
120
|
+
projected.mergeStrategy = row.mergeStrategy;
|
|
121
|
+
acc[key] = projected;
|
|
122
|
+
return acc;
|
|
123
|
+
}, Object.create(null)));
|
|
124
|
+
/**
|
|
125
|
+
* Which BODY field feeds each frontmatter key.
|
|
126
|
+
*
|
|
127
|
+
* `FIELD_CLASSIFICATION` above answers "who wins when frontmatter and body
|
|
128
|
+
* disagree"; this answers "and what is the body one called". They are separate
|
|
129
|
+
* questions and this one is display/routing knowledge, not preservation policy,
|
|
130
|
+
* so it does not widen the ADR-3408-governed table.
|
|
131
|
+
*
|
|
132
|
+
* #3699: `state update stopped_at …` reported `Field "stopped_at" not found in
|
|
133
|
+
* STATE.md` — byte-identical to what a genuinely absent field reports. The key
|
|
134
|
+
* IS present; it is a projection of a body field, and the message pointed away
|
|
135
|
+
* from the route that works. Naming the source is what makes the two cases
|
|
136
|
+
* distinguishable.
|
|
137
|
+
*
|
|
138
|
+
* Transcribed from `buildStateFrontmatter` (`state.cts`), which is the real
|
|
139
|
+
* deriver. That makes this a SECOND copy of knowledge that already exists, so it
|
|
140
|
+
* ships with a parity test asserting this key set equals the body-derived key set
|
|
141
|
+
* the builder actually emits (CLAUDE.md → Generative Fix Divergence). Keys the
|
|
142
|
+
* builder derives from disk, an external file, or the clock have no body source
|
|
143
|
+
* and are deliberately ABSENT here rather than mapped to a lie.
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
|
147
|
+
* (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key
|
|
148
|
+
* order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down
|
|
149
|
+
* to the body-sourced keys — the pre-#3873 literal already put `status`
|
|
150
|
+
* before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL`
|
|
151
|
+
* (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables
|
|
152
|
+
* disagreed with each other's order too, and this projection must reproduce
|
|
153
|
+
* ITS table's order specifically. Byte-identical to the pre-#3873 literal —
|
|
154
|
+
* same 8 keys, same order, same frozen null-prototype container with frozen
|
|
155
|
+
* per-key arrays. Pinned by `tests/state-transition.test.cjs`'s
|
|
156
|
+
* `bodySourceProjectionMatchesTodaysTable`.
|
|
157
|
+
*/
|
|
158
|
+
const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([
|
|
159
|
+
'current_phase',
|
|
160
|
+
'current_phase_name',
|
|
161
|
+
'current_plan',
|
|
162
|
+
'status',
|
|
163
|
+
'stopped_at',
|
|
164
|
+
'paused_at',
|
|
165
|
+
'last_activity',
|
|
166
|
+
'last_activity_desc',
|
|
167
|
+
]);
|
|
168
|
+
exports.FRONTMATTER_BODY_SOURCE = Object.freeze(FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => {
|
|
169
|
+
const row = STATE_FIELD_SCHEMA[key];
|
|
170
|
+
acc[key] = Object.freeze([...(row.bodySource ?? [])]);
|
|
171
|
+
return acc;
|
|
172
|
+
}, Object.create(null)));
|
|
173
|
+
/**
|
|
174
|
+
* The frontmatter keys whose body source lives inside `## Session`.
|
|
175
|
+
*
|
|
176
|
+
* #3374 established that these fields must be written where the reader reads
|
|
177
|
+
* them: `buildStateFrontmatter` harvests `Stopped At` / `Paused At` from the
|
|
178
|
+
* session section only, so a whole-body replace "lets a decoy `**Stopped at:**`
|
|
179
|
+
* line in an unrelated (e.g. archive) section absorb the refresh while the
|
|
180
|
+
* harvested session value stays stale" (`stateReplaceFieldInSession`'s own
|
|
181
|
+
* docstring). `updateCore` was still doing the whole-body replace.
|
|
182
|
+
*/
|
|
183
|
+
const SESSION_SCOPED_KEYS = new Set(['stopped_at', 'paused_at']);
|
|
184
|
+
/**
|
|
185
|
+
* The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
|
|
186
|
+
*/
|
|
187
|
+
function sessionLabelsForKey(key) {
|
|
188
|
+
if (!SESSION_SCOPED_KEYS.has(key))
|
|
189
|
+
return null;
|
|
190
|
+
const labels = exports.FRONTMATTER_BODY_SOURCE[key];
|
|
191
|
+
return { primary: labels[0], fallback: labels[1] ?? null };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
|
|
195
|
+
* `Stopped at`, `Paused At`). `null` for anything else.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
|
|
198
|
+
* spellings through one function and used it for the write, which made
|
|
199
|
+
* `state update stopped_at …` write the BODY line through the session writer —
|
|
200
|
+
* silently defeating the "frontmatter keys are not directly writable" contract
|
|
201
|
+
* this whole change exists to state, and reporting `updated: false` while having
|
|
202
|
+
* written. The write may only ever be reached by naming a body field.
|
|
203
|
+
*/
|
|
204
|
+
function sessionLabelsForBodyField(field) {
|
|
205
|
+
const key = frontmatterKeyForBodyField(field);
|
|
206
|
+
return key === null ? null : sessionLabelsForKey(key);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Would a session-scoped write actually land? Asks by attempting the real write
|
|
210
|
+
* with a throwaway value and seeing whether anything moved.
|
|
211
|
+
*
|
|
212
|
+
* Deliberately reuses the writer rather than re-deriving "where is the session
|
|
213
|
+
* section" — a separate scope check could disagree with the writer, and a
|
|
214
|
+
* presence check that disagrees with the write it guards is the whole bug class
|
|
215
|
+
* here. `stateReplaceFieldInSession` is replace-only and pure, so probing costs
|
|
216
|
+
* nothing and the result is discarded.
|
|
217
|
+
*/
|
|
218
|
+
function sessionSourceExists(body, labels) {
|
|
219
|
+
return (0, state_document_cjs_1.stateReplaceFieldInSession)(body, labels.primary, labels.fallback, '\u0000probe') !== body;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Own-property body-source lookup. `null` for a key with no body source (a
|
|
223
|
+
* disk/external/clock-derived key) and for anything not a frontmatter key.
|
|
224
|
+
*/
|
|
225
|
+
function getFrontmatterBodySource(field) {
|
|
226
|
+
if (!Object.prototype.hasOwnProperty.call(exports.FRONTMATTER_BODY_SOURCE, field))
|
|
227
|
+
return null;
|
|
228
|
+
return exports.FRONTMATTER_BODY_SOURCE[field];
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Reverse lookup: the frontmatter key a body field feeds, or `null`.
|
|
232
|
+
* Lets a failed body-field update name the frontmatter key that still carries a
|
|
233
|
+
* value (#3699 case D), instead of reporting a bare absence.
|
|
234
|
+
*/
|
|
235
|
+
function frontmatterKeyForBodyField(bodyField) {
|
|
236
|
+
const wanted = bodyField.trim().toLowerCase();
|
|
237
|
+
for (const key of Object.keys(exports.FRONTMATTER_BODY_SOURCE)) {
|
|
238
|
+
if (exports.FRONTMATTER_BODY_SOURCE[key].some((f) => f.toLowerCase() === wanted))
|
|
239
|
+
return key;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
75
243
|
/**
|
|
76
244
|
* Own-property classification lookup. Returns `null` for unknown fields
|
|
77
245
|
* (including inherited prototype methods like `toString`/`valueOf`).
|
|
@@ -82,103 +250,398 @@ function getFieldClassification(field) {
|
|
|
82
250
|
return exports.FIELD_CLASSIFICATION[field];
|
|
83
251
|
}
|
|
84
252
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
253
|
+
* #3836: the single source of truth for "which frontmatter keys carry the
|
|
254
|
+
* `preserve-when-unchanged` policy" — read straight off `FIELD_CLASSIFICATION`
|
|
255
|
+
* rather than re-typed as a hand-maintained literal array at each consumer.
|
|
256
|
+
* `cmdStateJson` (`state.cts`) previously hardcoded a 6-field list that had
|
|
257
|
+
* already drifted from this table by one row (`last_activity_desc`, #3258) —
|
|
258
|
+
* exactly the "second table parallel to the first" shape ADR-3473 exists to
|
|
259
|
+
* remove. `progress`/`milestone`/`milestone_name` carry a different
|
|
260
|
+
* preservation policy (`preserve-always` / `preserve-if-placeholder`) and are
|
|
261
|
+
* naturally excluded by the filter, not by a separate exclusion list.
|
|
88
262
|
*/
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
263
|
+
function getPreserveWhenUnchangedFields() {
|
|
264
|
+
return Object.keys(exports.FIELD_CLASSIFICATION).filter((field) => exports.FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged');
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Shared constructor body for `openStateTransaction` / `rebuildStateTransaction`
|
|
268
|
+
* (ADR-3473 §8.6 Decision 2/3). Validates `init.snapshot` and freezes the
|
|
269
|
+
* result so nothing downstream can mutate a transaction after construction
|
|
270
|
+
* (this is what makes the aliasing fix in `applyPreserveAlways`'s clone hold:
|
|
271
|
+
* the snapshot a caller passed in cannot be rewritten out from under it).
|
|
272
|
+
*
|
|
273
|
+
* `{}` and a null-prototype object are BOTH legal snapshots (Decision 2 / row
|
|
274
|
+
* 15 of the behavior table): `extractFrontmatter` returns `{}` for a document
|
|
275
|
+
* with no frontmatter or an unterminated one and never returns null or throws
|
|
276
|
+
* (`src/frontmatter.cts`), so `{}` is the honest snapshot of a real document —
|
|
277
|
+
* and `/gsd-health --repair`, which runs precisely when STATE.md is broken,
|
|
278
|
+
* depends on that staying legal. What is NOT legal is the snapshot being
|
|
279
|
+
* ABSENT (`null`/`undefined`/an array/a non-object): that is the caller
|
|
280
|
+
* forgetting to read the pre-write document at all, a construction failure,
|
|
281
|
+
* not a data question. Conflating "absent" with "empty" would turn the repair
|
|
282
|
+
* path's normal case into a hard throw.
|
|
283
|
+
*/
|
|
284
|
+
function createStateTransaction(kind, init, ctorName) {
|
|
285
|
+
if (init === null || typeof init !== 'object' || Array.isArray(init)) {
|
|
286
|
+
const err = new Error(`${ctorName}: expected an init object, got ${init === null ? 'null' : typeof init}. ` +
|
|
287
|
+
'Per ADR-3473 §8.6 / Decision 2, an absent init is a construction failure, distinct from ' +
|
|
288
|
+
'a legal empty snapshot ({}) — do not "fix" this by tolerating null.');
|
|
289
|
+
err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
|
|
290
|
+
err.constructorName = ctorName;
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
const snapshot = init.snapshot;
|
|
294
|
+
if (snapshot === null || snapshot === undefined || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
295
|
+
const err = new Error(`${ctorName}: init.snapshot is required and must be a non-array object (frontmatter map). ` +
|
|
296
|
+
`Per ADR-3473 §8.6 / Decision 2, an ABSENT snapshot is a construction failure — this is NOT ` +
|
|
297
|
+
'the same as a legal EMPTY snapshot ({}), which every executor accepts and simply finds ' +
|
|
298
|
+
'nothing to restore from (extractFrontmatter returns {} for a document with no parseable ' +
|
|
299
|
+
'frontmatter, and /gsd-health --repair depends on that staying legal). Pass {} explicitly ' +
|
|
300
|
+
'when the document truly has none; do not tolerate null/undefined here.');
|
|
301
|
+
err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
|
|
302
|
+
err.constructorName = ctorName;
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
return Object.freeze({
|
|
306
|
+
kind,
|
|
307
|
+
snapshot,
|
|
308
|
+
resync: init.resync === true,
|
|
309
|
+
deriveProgressKeys: init.deriveProgressKeys === true,
|
|
310
|
+
bodyDeltas: init.bodyDeltas,
|
|
311
|
+
explicitProgressField: init.explicitProgressField === true,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* ADR-3473 §8.6's `open()`: the default write-path transaction. Carries the
|
|
316
|
+
* pre-write snapshot and applies preservation (`applyStatePreservation` runs
|
|
317
|
+
* its full dispatch loop against it) — this is every STATE.md write EXCEPT
|
|
318
|
+
* the two sanctioned exceptions below.
|
|
319
|
+
*/
|
|
320
|
+
function openStateTransaction(init) {
|
|
321
|
+
return createStateTransaction('open', init, 'openStateTransaction');
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* ADR-3473 §8.6's `rebuild()`: the TYPED expression of ADR-3408 §8.3's closed
|
|
325
|
+
* list of sanctioned exceptions to the preservation pipeline. Exactly two
|
|
326
|
+
* callers may construct this: `cmdStateSync` (`state sync` re-derives
|
|
327
|
+
* frontmatter FROM the body per #905 — the body is authoritative and
|
|
328
|
+
* preservation would fight it) and `REGENERATE_STATE` (`/gsd-health --repair`'s
|
|
329
|
+
* factory reset — the whole point is to replace what's there). The snapshot
|
|
330
|
+
* is still carried (for §8.7's reporting) but `applyStatePreservation` skips
|
|
331
|
+
* its dispatch loop entirely for a `rebuild` transaction.
|
|
332
|
+
*
|
|
333
|
+
* This list is NOT debt to be paid down later — it is a closed, deliberate
|
|
334
|
+
* set. Adding a third caller is an amendment to ADR-3408 §8.3, not a call site
|
|
335
|
+
* convenience.
|
|
336
|
+
*/
|
|
337
|
+
function rebuildStateTransaction(init) {
|
|
338
|
+
return createStateTransaction('rebuild', init, 'rebuildStateTransaction');
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* ADR-3408 §8.2: an unenforced `preserve-when-unchanged` row throws. Both
|
|
342
|
+
* ends of this invariant are gsd-core's own source — a declared row the
|
|
343
|
+
* *caller code* forgot to wire via `bodyDeltas` — so it is a programming
|
|
344
|
+
* error, unreachable from any user document. The bright line, stated because
|
|
345
|
+
* conflating its two sides would be severe: a drifted, malformed, or
|
|
346
|
+
* unparseable user STATE.md NEVER reaches this throw (§8.5 governs that case
|
|
347
|
+
* with preserve-and-warn); this fires only when the *caller* omitted a
|
|
348
|
+
* `bodyDeltas` entry for a row the table itself declares. Getting this
|
|
349
|
+
* backwards turns every desynced project's `phase.complete` into a hard
|
|
350
|
+
* failure.
|
|
351
|
+
*/
|
|
352
|
+
function throwUnwiredRow(field) {
|
|
353
|
+
const err = new Error(`applyStatePreservation: preserve-when-unchanged row ${JSON.stringify(field)} reached the ` +
|
|
354
|
+
'executor with no wired ctx.bodyDeltas entry. This is an internal invariant violation (ADR-3408 ' +
|
|
355
|
+
'§8.2) — the caller (readModifyWriteStateMd) forgot to supply this field\'s body-source delta. ' +
|
|
356
|
+
'Add a bodyDeltas entry for this field per ADR-3408 §8.3, or remove the row from ' +
|
|
357
|
+
'FIELD_CLASSIFICATION if the field no longer needs this policy.');
|
|
358
|
+
err.code = 'STATE_PRESERVATION_UNWIRED_ROW';
|
|
359
|
+
err.field = field;
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Executor for `preservation: 'preserve-when-unchanged'` (ADR-3408 §8.1). The
|
|
364
|
+
* #1230 delta heuristic: restore the pre-write frontmatter snapshot when this
|
|
365
|
+
* write did not change the field's body source, and the snapshot is a real
|
|
366
|
+
* (non-empty-after-trim) curated value the derived value should not clobber.
|
|
367
|
+
*
|
|
368
|
+
* Every row carrying this policy — status, stopped_at, current_phase_name,
|
|
369
|
+
* current_phase, current_plan, paused_at, last_activity_desc — is honored by
|
|
370
|
+
* this ONE executor; `cls.guard` is the only field-specific variation (the
|
|
371
|
+
* closed vocabulary of ADR-3408 Decision 1).
|
|
372
|
+
*
|
|
373
|
+
* Exported (ADR-3408 §8.5 / D3) so `cmdStateJson` (state.cts) — a read-only
|
|
374
|
+
* path with no transform of its own — can route its stale-vs-fresh decision
|
|
375
|
+
* through the SAME executor the write path uses, rather than maintaining a
|
|
376
|
+
* third private copy of this policy. `cmdStateJson` calls this directly
|
|
377
|
+
* (not the full `applyStatePreservation` dispatch loop) so its read stays
|
|
378
|
+
* scoped to exactly the fields it has always governed and never touches
|
|
379
|
+
* `progress` or `milestone*`, which are different policies with their own
|
|
380
|
+
* read-path rules (`shouldPreserveExistingProgress`, `preserve-if-placeholder`).
|
|
381
|
+
*/
|
|
382
|
+
function applyPreserveWhenUnchanged(field, cls, ctx) {
|
|
383
|
+
// 1. A declared row with no wired delta is an internal invariant violation
|
|
384
|
+
// — throw (ADR-3408 §8.2). Never reached for a user-document defect: the
|
|
385
|
+
// production caller (readModifyWriteStateMd) wires every preserve-when-
|
|
386
|
+
// unchanged row unconditionally.
|
|
387
|
+
const delta = ctx.bodyDeltas ? ctx.bodyDeltas[field] : undefined;
|
|
388
|
+
if (!delta)
|
|
389
|
+
throwUnwiredRow(field);
|
|
390
|
+
// 2. Only a real, non-whitespace-only curated string is worth restoring
|
|
391
|
+
// (#3468: tightened from `.length > 0` to a trimmed check — a whitespace-
|
|
392
|
+
// only snapshot is not a real curated value).
|
|
393
|
+
const snapshot = ctx.snapshot[field];
|
|
394
|
+
if (typeof snapshot !== 'string' || snapshot.trim().length === 0)
|
|
395
|
+
return;
|
|
396
|
+
// 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
|
|
397
|
+
// Exact-match, case-sensitive — 'Unknown' is a real value and IS restored.
|
|
398
|
+
if (cls.guard === 'non-sentinel-unknown' && snapshot === 'unknown')
|
|
399
|
+
return;
|
|
400
|
+
// 4. The body source changed this write → the freshly-derived value wins.
|
|
401
|
+
if (delta.pre !== delta.post)
|
|
402
|
+
return;
|
|
403
|
+
// 5. Already correct → no-op (avoid a spurious `mutated=true`).
|
|
404
|
+
if (ctx.postFm[field] === snapshot)
|
|
405
|
+
return;
|
|
406
|
+
// 6. Restore.
|
|
407
|
+
ctx.postFm[field] = snapshot;
|
|
408
|
+
ctx.mutated = true;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The closed set of `progress` keys whose non-zero value means "a real
|
|
412
|
+
* measurement happened" (ADR-3473 §8.6 / #3756).
|
|
413
|
+
*/
|
|
414
|
+
const PROGRESS_TOTAL_KEYS = ['total_phases', 'total_plans'];
|
|
415
|
+
/**
|
|
416
|
+
* Did this row's derived (or curated) value represent a REAL measurement?
|
|
417
|
+
*
|
|
418
|
+
* For a `progress-ratchet` row (today, only `progress`): an empty
|
|
419
|
+
* milestone-scoped scan is "nothing was measured", not "zero is done"
|
|
420
|
+
* (#3756, and the convention #3233 established — `computeProgressPercent`
|
|
421
|
+
* already returns `null` for an empty denominator). Only the TOTALS decide:
|
|
422
|
+
* `completed_*` being zero is normal for a real project, so it is
|
|
423
|
+
* deliberately excluded from this check. A non-object / absent / negative /
|
|
424
|
+
* non-numeric total is NOT a measurement, so it degrades TOWARD preservation,
|
|
425
|
+
* never toward deletion — `toFiniteNumber` (not a raw `=== 0`/`> 0` test)
|
|
426
|
+
* because frontmatter scalars arrive as STRINGS (`"0"`, not `0`).
|
|
427
|
+
*
|
|
428
|
+
* For any other row (no `progress-ratchet` strategy) the question is
|
|
429
|
+
* meaningless, so it answers `true` and behavior is unchanged — this
|
|
430
|
+
* function is only ever consulted from inside the `preserve-always` /
|
|
431
|
+
* `progress-ratchet` branch below.
|
|
432
|
+
*/
|
|
433
|
+
function scanMeasuredSomething(cls, value) {
|
|
434
|
+
if (cls.mergeStrategy !== 'progress-ratchet')
|
|
435
|
+
return true;
|
|
436
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
437
|
+
return false;
|
|
438
|
+
const rec = value;
|
|
439
|
+
return PROGRESS_TOTAL_KEYS.some((k) => ((0, state_document_cjs_2.toFiniteNumber)(rec[k]) ?? 0) > 0);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Deep-clone a curated value before it re-enters `postFm` (ADR-3473 §8.6,
|
|
443
|
+
* "Defects fixed inline" / aliasing). `structuredClone` is a Node built-in;
|
|
444
|
+
* this repo takes no external deps for it. WHY a clone and not a reference
|
|
445
|
+
* assignment: the transaction's `snapshot` is now the SAME object §8.7's
|
|
446
|
+
* reporting will diff against. Assigning the nested curated object by
|
|
447
|
+
* reference would make `postFm.progress` alias that snapshot, so a later
|
|
448
|
+
* in-place mutation of `postFm` would silently rewrite the snapshot too, and
|
|
449
|
+
* the diff would report "no change" for a field that did change.
|
|
450
|
+
*/
|
|
451
|
+
function cloneCurated(value) {
|
|
452
|
+
return structuredClone(value);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Structural equality for a restored value vs. what `postFm` already held
|
|
456
|
+
* (ADR-3473 §8.6, "Defects fixed inline" / #948 no-op-write family).
|
|
457
|
+
* `JSON.stringify` compare when either side is an object (the `progress`
|
|
458
|
+
* block), `===` otherwise. WHY: `applyPreserveAlways` previously set
|
|
459
|
+
* `ctx.mutated = true` unconditionally at its tail, even when it restored a
|
|
460
|
+
* value identical to what was already there — driving a write that changes
|
|
461
|
+
* nothing but still bumps `last_updated` / restamps `state_head`.
|
|
462
|
+
* `applyPreserveWhenUnchanged` already guards this (its step 5); this brings
|
|
463
|
+
* the two executors into agreement.
|
|
464
|
+
*/
|
|
465
|
+
function preservedValuesEqual(a, b) {
|
|
466
|
+
if (typeof a === 'object' || typeof b === 'object') {
|
|
467
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
468
|
+
}
|
|
469
|
+
return a === b;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Executor for `preservation: 'preserve-always'` (ADR-3408 §8.1). Only
|
|
473
|
+
* `progress` carries this policy today. Preserves #3242/#1446/#2440/#2969
|
|
474
|
+
* semantics byte-for-byte on every row the behavior table marks unchanged;
|
|
475
|
+
* ADR-3473 §8.6 fixes the #3756 defect (a resyncing write that measured
|
|
476
|
+
* nothing must not drop a real curated block) plus the two "Defects fixed
|
|
477
|
+
* inline" no-op-write / aliasing bugs.
|
|
478
|
+
*/
|
|
479
|
+
function applyPreserveAlways(field, cls, ctx) {
|
|
480
|
+
const curated = ctx.snapshot[field];
|
|
481
|
+
if (!curated)
|
|
482
|
+
return;
|
|
483
|
+
const derived = ctx.postFm[field];
|
|
484
|
+
const derivedMeasured = scanMeasuredSomething(cls, derived);
|
|
485
|
+
const curatedMeasured = scanMeasuredSomething(cls, curated);
|
|
486
|
+
// On a resyncing write the fresh derivation is authoritative — UNLESS it
|
|
487
|
+
// measured nothing while the curated block did (#3756), AND the caller did
|
|
488
|
+
// not explicitly name a progress-affecting field this write. The
|
|
489
|
+
// unmeasured-scan guard exists to stop an INCIDENTAL resync (e.g. `state
|
|
490
|
+
// add-decision`, whose `resync` defaults true for reasons that have
|
|
491
|
+
// nothing to do with `progress`) from dropping a real curated block when a
|
|
492
|
+
// milestone-scoped disk scan measures nothing (#3756's archived-milestone
|
|
493
|
+
// case). It must not also block a write the user pointed AT `progress` on
|
|
494
|
+
// purpose: `preserve-always`'s own contract is "never overwrite unless the
|
|
495
|
+
// caller explicitly names this field" (FIELD_CLASSIFICATION doc comment),
|
|
496
|
+
// and `state update Progress` / `state patch Progress=...` are exactly
|
|
497
|
+
// that naming — the resync they trigger must win even when the disk scan
|
|
498
|
+
// it also drives (e.g. because there are no phase dirs at all) reads as
|
|
499
|
+
// "unmeasured" (tests/frontmatter.test.cjs: "state.update \"Progress\"
|
|
500
|
+
// resyncs progress frontmatter from the updated body", pre-existing, #3242).
|
|
501
|
+
if (ctx.resync && (derivedMeasured || !curatedMeasured || ctx.explicitProgressField))
|
|
502
|
+
return;
|
|
503
|
+
let next;
|
|
504
|
+
if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && derived && derivedMeasured) {
|
|
505
|
+
// #2440: total_plans and total_phases always take the derived (post-sync)
|
|
506
|
+
// value even under !resync. This is used by cmdStatePlannedPhase where
|
|
507
|
+
// total_plans must correct upward after plans are added. For body-only
|
|
508
|
+
// writes (state.update/patch without the flag), the wholesale restore
|
|
509
|
+
// below preserves everything as before — the #3242 Bug A protection
|
|
510
|
+
// stays fully in force.
|
|
511
|
+
const curatedRecord = curated;
|
|
512
|
+
const derivedRecord = (derived ?? {});
|
|
513
|
+
const merged = { ...derivedRecord };
|
|
514
|
+
if (curatedRecord) {
|
|
515
|
+
// #2440: total_plans and total_phases always take the derived value.
|
|
516
|
+
// #2969: completed_plans and completed_phases take the derived value
|
|
517
|
+
// when it is GREATER than the curated value (gap-closure plans that
|
|
518
|
+
// completed after the plan count grew) — ratcheting UP only, never
|
|
519
|
+
// deriving downward (preserves the #3242 curated-progress protection
|
|
520
|
+
// for cases unrelated to plan-count growth, e.g. a deleted SUMMARY).
|
|
521
|
+
// percent also takes the derived value — the resync recomputed it from
|
|
522
|
+
// disk counts, and a stale curated percent would be incoherent against
|
|
523
|
+
// the ratcheted-up completed counts (e.g. 54/54 at 93%).
|
|
524
|
+
const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
|
|
525
|
+
for (const [key, value] of Object.entries(curatedRecord)) {
|
|
526
|
+
if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
|
|
527
|
+
continue;
|
|
528
|
+
if (ratchetUpKeys.has(key)) {
|
|
529
|
+
const derivedNum = typeof derivedRecord[key] === 'number' ? derivedRecord[key] : -Infinity;
|
|
530
|
+
const curatedNum = typeof value === 'number' ? value : -Infinity;
|
|
531
|
+
// Take the derived value only when it ratchets up (strictly
|
|
532
|
+
// greater — #2969's `>` not `>=`); else keep curated.
|
|
533
|
+
if (derivedNum > curatedNum)
|
|
126
534
|
continue;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (derivedNum > curatedNum)
|
|
132
|
-
continue;
|
|
133
|
-
merged[key] = value;
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
merged[key] = value;
|
|
137
|
-
}
|
|
535
|
+
merged[key] = value;
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
merged[key] = value;
|
|
138
539
|
}
|
|
139
540
|
}
|
|
140
|
-
postFm['progress'] = merged;
|
|
141
541
|
}
|
|
142
|
-
|
|
143
|
-
|
|
542
|
+
next = merged;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
next = cloneCurated(curated);
|
|
546
|
+
}
|
|
547
|
+
if (preservedValuesEqual(ctx.postFm[field], next))
|
|
548
|
+
return;
|
|
549
|
+
ctx.postFm[field] = next;
|
|
550
|
+
ctx.mutated = true;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Executor for `preservation: 'preserve-if-placeholder'` (ADR-3408 §8.1).
|
|
554
|
+
* `milestone` and `milestone_name` both carry this policy in the table, and
|
|
555
|
+
* both rows dispatch into this SAME executor body — no branch is selected by
|
|
556
|
+
* field name (ADR-3408 §8.1). The body always restores the name+version pair
|
|
557
|
+
* together (#948/#2135), ignoring which of the two rows triggered the call;
|
|
558
|
+
* this is deliberately safe because the executor is idempotent: whichever
|
|
559
|
+
* row fires first either performs the restore (after which the second row's
|
|
560
|
+
* call recomputes against already-restored state and finds nothing left to
|
|
561
|
+
* do) or finds no placeholder to restore (in which case the second row's
|
|
562
|
+
* call, seeing the same unchanged inputs, reaches the same conclusion). Two
|
|
563
|
+
* dispatches per write converge to the identical single-pass result, so the
|
|
564
|
+
* field argument itself is unused here — it exists only to satisfy the
|
|
565
|
+
* shared executor signature every policy branch in the dispatch loop shares.
|
|
566
|
+
*/
|
|
567
|
+
function applyPreserveIfPlaceholder(_field, _cls, ctx) {
|
|
568
|
+
const MILESTONE_PLACEHOLDER = 'milestone';
|
|
569
|
+
const derivedName = ctx.postFm['milestone_name'];
|
|
570
|
+
const derivedLooksLikeName = typeof derivedName === 'string'
|
|
571
|
+
&& derivedName.length > 0
|
|
572
|
+
&& derivedName !== MILESTONE_PLACEHOLDER
|
|
573
|
+
&& !/^[\s—–:-]/.test(derivedName);
|
|
574
|
+
const snapshotName = ctx.snapshot['milestone_name'];
|
|
575
|
+
const snapshotNameIsReal = typeof snapshotName === 'string'
|
|
576
|
+
&& snapshotName.length > 0
|
|
577
|
+
&& snapshotName !== MILESTONE_PLACEHOLDER;
|
|
578
|
+
if (derivedLooksLikeName || !snapshotNameIsReal)
|
|
579
|
+
return;
|
|
580
|
+
if (ctx.postFm['milestone_name'] !== snapshotName) {
|
|
581
|
+
ctx.postFm['milestone_name'] = snapshotName;
|
|
582
|
+
ctx.mutated = true;
|
|
583
|
+
}
|
|
584
|
+
const snapshotVersion = ctx.snapshot['milestone'];
|
|
585
|
+
if (typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
|
|
586
|
+
ctx.postFm['milestone'] !== snapshotVersion) {
|
|
587
|
+
ctx.postFm['milestone'] = snapshotVersion;
|
|
588
|
+
ctx.mutated = true;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Executor for `preservation: 'derive'`. Explicit no-op — the sync's
|
|
593
|
+
* freshly-derived value stands untouched. Naming this executor (rather than
|
|
594
|
+
* skipping `derive` rows by omission) is what makes ADR-3408 §8.2's throw
|
|
595
|
+
* decidable: "policy says do nothing" is now distinguishable from "nobody
|
|
596
|
+
* wired this", because every member of `FieldPreservation` reaches an
|
|
597
|
+
* executor.
|
|
598
|
+
*/
|
|
599
|
+
function applyDerive(_field, _cls, _ctx) {
|
|
600
|
+
// No-op by design — see docstring.
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Pure, table-driven post-sync preservation (ADR-3408 §8.1). One loop over
|
|
604
|
+
* `FIELD_CLASSIFICATION`, dispatching on the row's `preservation` value —
|
|
605
|
+
* never on the field name. Mutates `postFm` in place to mirror the
|
|
606
|
+
* pre-#3468 inline block (which also mutated in place) and returns whether
|
|
607
|
+
* any field was restored.
|
|
608
|
+
*/
|
|
609
|
+
function applyStatePreservation(input) {
|
|
610
|
+
const { transaction } = input;
|
|
611
|
+
// A `rebuild()` transaction still carries the snapshot (§8.7's reporting
|
|
612
|
+
// needs it) but must not run preservation at all: `state sync` / `REGENERATE_STATE`
|
|
613
|
+
// exist to let the body / factory-reset win, and restoring curated values
|
|
614
|
+
// over that would re-lock exactly what the command was invoked to replace.
|
|
615
|
+
if (transaction.kind === 'rebuild') {
|
|
616
|
+
return { postFm: input.postFm, mutated: false };
|
|
617
|
+
}
|
|
618
|
+
const ctx = {
|
|
619
|
+
postFm: input.postFm,
|
|
620
|
+
snapshot: transaction.snapshot,
|
|
621
|
+
resync: transaction.resync,
|
|
622
|
+
deriveProgressKeys: transaction.deriveProgressKeys === true,
|
|
623
|
+
bodyDeltas: transaction.bodyDeltas,
|
|
624
|
+
mutated: false,
|
|
625
|
+
explicitProgressField: transaction.explicitProgressField === true,
|
|
626
|
+
};
|
|
627
|
+
for (const field of Object.keys(exports.FIELD_CLASSIFICATION)) {
|
|
628
|
+
const cls = getFieldClassification(field);
|
|
629
|
+
if (!cls)
|
|
630
|
+
continue;
|
|
631
|
+
if (cls.preservation === 'preserve-when-unchanged') {
|
|
632
|
+
applyPreserveWhenUnchanged(field, cls, ctx);
|
|
633
|
+
}
|
|
634
|
+
else if (cls.preservation === 'preserve-always') {
|
|
635
|
+
applyPreserveAlways(field, cls, ctx);
|
|
636
|
+
}
|
|
637
|
+
else if (cls.preservation === 'preserve-if-placeholder') {
|
|
638
|
+
applyPreserveIfPlaceholder(field, cls, ctx);
|
|
639
|
+
}
|
|
640
|
+
else if (cls.preservation === 'derive') {
|
|
641
|
+
applyDerive(field, cls, ctx);
|
|
144
642
|
}
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
// status — #1230 body-delta heuristic. Table: preserve-when-unchanged.
|
|
148
|
-
const statusCls = getFieldClassification('status');
|
|
149
|
-
if (statusCls !== null &&
|
|
150
|
-
statusCls.preservation === 'preserve-when-unchanged' &&
|
|
151
|
-
input.postBodyStatus === input.preBodyStatus &&
|
|
152
|
-
typeof preFmSnapshot['status'] === 'string' &&
|
|
153
|
-
preFmSnapshot['status'].length > 0 &&
|
|
154
|
-
preFmSnapshot['status'] !== 'unknown' &&
|
|
155
|
-
postFm['status'] !== preFmSnapshot['status']) {
|
|
156
|
-
postFm['status'] = preFmSnapshot['status'];
|
|
157
|
-
mutated = true;
|
|
158
|
-
}
|
|
159
|
-
// stopped_at — same #1230 body-delta heuristic. Table: preserve-when-unchanged.
|
|
160
|
-
const stoppedCls = getFieldClassification('stopped_at');
|
|
161
|
-
if (stoppedCls !== null &&
|
|
162
|
-
stoppedCls.preservation === 'preserve-when-unchanged' &&
|
|
163
|
-
input.postBodyStoppedAt === input.preBodyStoppedAt &&
|
|
164
|
-
typeof preFmSnapshot['stopped_at'] === 'string' &&
|
|
165
|
-
preFmSnapshot['stopped_at'].length > 0 &&
|
|
166
|
-
postFm['stopped_at'] !== preFmSnapshot['stopped_at']) {
|
|
167
|
-
postFm['stopped_at'] = preFmSnapshot['stopped_at'];
|
|
168
|
-
mutated = true;
|
|
169
|
-
}
|
|
170
|
-
// current_phase_name — curated (#1743/#1695). Table: preserve-always.
|
|
171
|
-
const phaseNameCls = getFieldClassification('current_phase_name');
|
|
172
|
-
if (phaseNameCls !== null &&
|
|
173
|
-
phaseNameCls.preservation === 'preserve-always' &&
|
|
174
|
-
input.postBodyPhaseSource === input.preBodyPhaseSource &&
|
|
175
|
-
typeof preFmSnapshot['current_phase_name'] === 'string' &&
|
|
176
|
-
preFmSnapshot['current_phase_name'].length > 0 &&
|
|
177
|
-
postFm['current_phase_name'] !== preFmSnapshot['current_phase_name']) {
|
|
178
|
-
postFm['current_phase_name'] = preFmSnapshot['current_phase_name'];
|
|
179
|
-
mutated = true;
|
|
180
|
-
}
|
|
181
|
-
return { postFm, mutated };
|
|
643
|
+
}
|
|
644
|
+
return { postFm: ctx.postFm, mutated: ctx.mutated };
|
|
182
645
|
}
|
|
183
646
|
// ----------------------------------------------------------------------------
|
|
184
647
|
// Body section constants (ADR-1769 §6 — single writer after migration)
|
|
@@ -261,12 +724,14 @@ function beginPhaseCore(content, intent, deps) {
|
|
|
261
724
|
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
|
262
725
|
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
|
263
726
|
// before the body pipe-table row if full content is passed.
|
|
264
|
-
const
|
|
265
|
-
|
|
727
|
+
const { reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
728
|
+
// #3881 review, finding 5: `body` is deliberately a LITERAL `stripFrontmatter(content)`
|
|
729
|
+
// assignment here rather than the helper's own `body` (which the destructure above skips) —
|
|
730
|
+
// scripts/lint-state-write-path-drift.cjs's Axis 3 backward scan is a single-hop textual
|
|
731
|
+
// pattern match, not real dataflow, and only recognizes `body = stripFrontmatter(...)` written
|
|
732
|
+
// out at the call site. `stripFrontmatter` is pure and idempotent, so computing it here (in
|
|
733
|
+
// addition to the helper's own internal call) changes nothing observable.
|
|
266
734
|
let body = stripFrontmatter(content);
|
|
267
|
-
const reassemble = (b) => hasFrontmatter
|
|
268
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
269
|
-
: b;
|
|
270
735
|
const today = deps.clock.localToday();
|
|
271
736
|
// Consult the field-classification table for the frontmatter keys this
|
|
272
737
|
// transition touches (codex Phase 1 review: "table not consulted by
|
|
@@ -299,7 +764,7 @@ function beginPhaseCore(content, intent, deps) {
|
|
|
299
764
|
// Extract from body (not full content) so the YAML `status:` key cannot
|
|
300
765
|
// shadow the body Status field (#1255).
|
|
301
766
|
const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || '';
|
|
302
|
-
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus);
|
|
767
|
+
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${(0, pattern_cjs_1.escapeRegex)(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus);
|
|
303
768
|
// Status update (applies on both first-time and resume — Status is always refreshed).
|
|
304
769
|
tryField('Status', `Executing Phase ${intent.phaseNumber}`);
|
|
305
770
|
// Last Activity date — safe to refresh on resume (tracks when execute-phase ran).
|
|
@@ -507,6 +972,25 @@ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastAc
|
|
|
507
972
|
return content;
|
|
508
973
|
let sectionBody = content.slice(span.start, span.end);
|
|
509
974
|
let mutated = false;
|
|
975
|
+
// #3395: Phase is always replaced when a caller passes it — system-derived,
|
|
976
|
+
// not executor-authored (same rule as Plan below). plannedPhaseCore uses
|
|
977
|
+
// this so the transition that declares phase N planned also owns the `Phase:`
|
|
978
|
+
// line the frontmatter resync and `state json` re-derive current_phase from;
|
|
979
|
+
// before, the line survived stale from a previous phase and every
|
|
980
|
+
// body-derived consumer kept reading it (#948 class).
|
|
981
|
+
if (fields.phase) {
|
|
982
|
+
if (/^Phase:/m.test(sectionBody)) {
|
|
983
|
+
sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${fields.phase}`);
|
|
984
|
+
mutated = true;
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Phase', fields.phase);
|
|
988
|
+
if (replaced !== null) {
|
|
989
|
+
sectionBody = replaced;
|
|
990
|
+
mutated = true;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
510
994
|
if (fields.status) {
|
|
511
995
|
const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Status', statusDefaults, fields.status);
|
|
512
996
|
if (replaced !== null && replaced !== sectionBody) {
|
|
@@ -564,12 +1048,35 @@ function advancePlanCore(content, deps) {
|
|
|
564
1048
|
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
|
565
1049
|
// before the body field if full content is passed (codex Phase 2 review:
|
|
566
1050
|
// HIGH blocking finding — same pattern beginPhaseCore already handles).
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1051
|
+
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1052
|
+
let body = initialBody;
|
|
1053
|
+
// #3807: refuse a Current Position section carrying more than one `Phase:`
|
|
1054
|
+
// entry BEFORE mutating. The plan fields below come from document-wide
|
|
1055
|
+
// first-match extraction, so in a wave-log style section (one entry per
|
|
1056
|
+
// completed wave) the FIRST entry's plan counter silently advanced — in the
|
|
1057
|
+
// reporting incident, a hard-gated final plan 7→8 of 8 — while the entry
|
|
1058
|
+
// the caller meant sat untouched below it, with advanced:true and no
|
|
1059
|
+
// ambiguity signal. advance-plan now refuses before acting. Scoped via the
|
|
1060
|
+
// #2956 canonical locator (stateCurrentPositionSlice — H2 or H3 heading,
|
|
1061
|
+
// the same one cmdStateAdvancePlan's own milestone read uses); NO whole-body
|
|
1062
|
+
// fallback — a legacy-format document with unrelated `Phase:` history lines
|
|
1063
|
+
// elsewhere has no section to disambiguate and must keep its current
|
|
1064
|
+
// behavior rather than be falsely refused.
|
|
1065
|
+
const positionScope = (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
|
|
1066
|
+
if (positionScope !== null) {
|
|
1067
|
+
const phaseCandidates = (positionScope.match(/^Phase:.*$/gm) || []);
|
|
1068
|
+
if (phaseCandidates.length > 1) {
|
|
1069
|
+
return {
|
|
1070
|
+
content,
|
|
1071
|
+
updated: [],
|
|
1072
|
+
data: {
|
|
1073
|
+
error: true,
|
|
1074
|
+
reason: 'ambiguous_position_phase',
|
|
1075
|
+
phase_candidates: phaseCandidates.map((l) => l.trim()),
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
573
1080
|
// Parse plan number — legacy first, then compound.
|
|
574
1081
|
const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
|
|
575
1082
|
const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
|
|
@@ -676,6 +1183,7 @@ function completePhaseCore(content, intent, deps) {
|
|
|
676
1183
|
'current_plan',
|
|
677
1184
|
'last_activity',
|
|
678
1185
|
'last_activity_desc',
|
|
1186
|
+
'stopped_at',
|
|
679
1187
|
'progress',
|
|
680
1188
|
]) {
|
|
681
1189
|
const cls = getFieldClassification(fmKey);
|
|
@@ -686,12 +1194,8 @@ function completePhaseCore(content, intent, deps) {
|
|
|
686
1194
|
}
|
|
687
1195
|
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
|
688
1196
|
// so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
let body = stripFrontmatter(content);
|
|
692
|
-
const reassemble = (b) => hasFrontmatter
|
|
693
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
694
|
-
: b;
|
|
1197
|
+
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1198
|
+
let body = initialBody;
|
|
695
1199
|
// Current Phase — preserve the existing `of <total>` shape and the phase name
|
|
696
1200
|
// in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
|
|
697
1201
|
const phaseValue = intent.nextPhaseNum || intent.phaseNum;
|
|
@@ -720,8 +1224,8 @@ function completePhaseCore(content, intent, deps) {
|
|
|
720
1224
|
updated.push('Current Phase');
|
|
721
1225
|
}
|
|
722
1226
|
// Current Phase Name — only written when a next-phase display name is known
|
|
723
|
-
// (#1743/#1695: classified curated/preserve-
|
|
724
|
-
// NOT clear an existing curated value).
|
|
1227
|
+
// (#1743/#1695: classified curated/preserve-when-unchanged, so an absent
|
|
1228
|
+
// name does NOT clear an existing curated value).
|
|
725
1229
|
if (nextPhaseDisplayName) {
|
|
726
1230
|
const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', nextPhaseDisplayName);
|
|
727
1231
|
if (after) {
|
|
@@ -766,6 +1270,28 @@ function completePhaseCore(content, intent, deps) {
|
|
|
766
1270
|
body = ladAfter;
|
|
767
1271
|
updated.push('Last Activity Description');
|
|
768
1272
|
}
|
|
1273
|
+
// Stopped At — #3374: write the continuity line this transition implies.
|
|
1274
|
+
// The frontmatter `stopped_at` is a projection of this body line
|
|
1275
|
+
// (source: 'body' in FIELD_CLASSIFICATION), and phase completion is exactly
|
|
1276
|
+
// the event the line describes — leaving it stale made the post-sync harvest
|
|
1277
|
+
// overwrite a fresher frontmatter value with pre-completion prose on every
|
|
1278
|
+
// completion (#3374), and left the workflow's later prose refresh as a
|
|
1279
|
+
// divergence source. Session-SCOPED replace (stateReplaceFieldInSession):
|
|
1280
|
+
// the harvest reads only the session section, so the write must target the
|
|
1281
|
+
// same scope — a whole-body replace let a decoy `**Stopped at:**` line in an
|
|
1282
|
+
// unrelated section absorb the refresh. Replace-only (no insertion): a
|
|
1283
|
+
// STATE.md with no session continuity line keeps its shape, and the
|
|
1284
|
+
// unchanged body source then lets the preservation delta keep an existing
|
|
1285
|
+
// frontmatter value. Last-phase wording reuses the ADR-2207 status phrase;
|
|
1286
|
+
// milestone termination wording stays owned by milestoneCompleteCore.
|
|
1287
|
+
const stoppedAtLine = intent.isLastPhase
|
|
1288
|
+
? `Phase ${intent.phaseNum} complete — all phases complete`
|
|
1289
|
+
: `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, ready to plan Phase ${intent.nextPhaseNum}` : ''}`;
|
|
1290
|
+
const stoppedAfter = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, 'Stopped At', 'Stopped at', stoppedAtLine);
|
|
1291
|
+
if (stoppedAfter !== body) {
|
|
1292
|
+
body = stoppedAfter;
|
|
1293
|
+
updated.push('Stopped At');
|
|
1294
|
+
}
|
|
769
1295
|
// Progress block — re-derive completed/total phases from the roadmap when
|
|
770
1296
|
// available (milestone-wide source of truth), then recompute the percent.
|
|
771
1297
|
// Only runs when a Completed Phases field exists (the existing guard).
|
|
@@ -821,7 +1347,10 @@ function completePhaseCore(content, intent, deps) {
|
|
|
821
1347
|
* per-phase body fields after plan-phase runs: Status (template-aware — only
|
|
822
1348
|
* replaces handler-generated values, preserving executor-authored ones),
|
|
823
1349
|
* Total Plans in Phase, Last Activity (template-aware), Last Activity
|
|
824
|
-
* Description, and the ## Current Position section
|
|
1350
|
+
* Description, and the ## Current Position section — including its `Phase:`
|
|
1351
|
+
* line, which this transition owns (#3395: the line is the body source
|
|
1352
|
+
* `current_phase` re-derives from, so it must not survive stale from a
|
|
1353
|
+
* previous phase). The adapter wraps this in
|
|
825
1354
|
* `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.*
|
|
826
1355
|
* frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1).
|
|
827
1356
|
*
|
|
@@ -840,12 +1369,8 @@ function plannedPhaseCore(content, intent, deps) {
|
|
|
840
1369
|
}
|
|
841
1370
|
}
|
|
842
1371
|
// #1255: body-field replacements operate on body only.
|
|
843
|
-
const existingFm =
|
|
844
|
-
|
|
845
|
-
let body = stripFrontmatter(content);
|
|
846
|
-
const reassemble = (b) => hasFrontmatter
|
|
847
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
848
|
-
: b;
|
|
1372
|
+
const { existingFm, hasFrontmatter, body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1373
|
+
let body = initialBody;
|
|
849
1374
|
const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
|
850
1375
|
const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
|
851
1376
|
// Status — template-aware (preserve executor-authored values).
|
|
@@ -874,9 +1399,20 @@ function plannedPhaseCore(content, intent, deps) {
|
|
|
874
1399
|
body = ladResult;
|
|
875
1400
|
updated.push('Last Activity Description');
|
|
876
1401
|
}
|
|
877
|
-
// ## Current Position section — Status + Last activity
|
|
1402
|
+
// ## Current Position section — Phase + Status + Last activity.
|
|
1403
|
+
// #3395: plannedPhaseCore owns the `Phase:` line for the same reason
|
|
1404
|
+
// beginPhaseCore/completePhaseCore do — it is the body source the frontmatter
|
|
1405
|
+
// resync and `state json` re-derive `current_phase` from. Before, a stale
|
|
1406
|
+
// line from a previous phase survived this transition and every
|
|
1407
|
+
// body-derived consumer kept reading it (the write path was already
|
|
1408
|
+
// protected by the #3258 preserve-when-unchanged row; the source itself was
|
|
1409
|
+
// never refreshed). The label mirrors beginPhaseCore's `N (Name) — EXECUTING`
|
|
1410
|
+
// convention with this transition's status vocabulary ("Ready to execute").
|
|
1411
|
+
// Phase is system-derived, always replaced (Knuth invariant does not apply);
|
|
1412
|
+
// Status / Last activity stay template-aware.
|
|
878
1413
|
const beforePos = body;
|
|
879
1414
|
body = mutateCurrentPositionForAdvance(body, {
|
|
1415
|
+
phase: `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — READY TO EXECUTE`,
|
|
880
1416
|
status: 'Ready to execute',
|
|
881
1417
|
lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`,
|
|
882
1418
|
}, statusDefaults, lastActivityDefaults);
|
|
@@ -911,10 +1447,11 @@ function plannedPhaseCore(content, intent, deps) {
|
|
|
911
1447
|
* preserved.
|
|
912
1448
|
*
|
|
913
1449
|
* This is a destructive reset intent: it intentionally overwrites the curated
|
|
914
|
-
* `progress` / `current_phase_name`
|
|
915
|
-
* a new milestone starts from zero. That is the intent's
|
|
916
|
-
* violation of the field-classification table — the table
|
|
917
|
-
* state RMW transitions; a milestone boundary is an
|
|
1450
|
+
* `progress` (preserve-always) / `current_phase_name` (preserve-when-unchanged)
|
|
1451
|
+
* fields because a new milestone starts from zero. That is the intent's
|
|
1452
|
+
* contract, not a violation of the field-classification table — the table
|
|
1453
|
+
* governs the steady-state RMW transitions; a milestone boundary is an
|
|
1454
|
+
* explicit reset.
|
|
918
1455
|
*
|
|
919
1456
|
* The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT
|
|
920
1457
|
* `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter
|
|
@@ -1076,12 +1613,8 @@ function milestoneCompleteCore(content, intent, deps) {
|
|
|
1076
1613
|
}
|
|
1077
1614
|
}
|
|
1078
1615
|
// #1255: body-field replacements operate on body only.
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
let body = stripFrontmatter(content);
|
|
1082
|
-
const reassemble = (b) => hasFrontmatter
|
|
1083
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
1084
|
-
: b;
|
|
1616
|
+
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1617
|
+
let body = initialBody;
|
|
1085
1618
|
// Status — `<version> milestone complete`.
|
|
1086
1619
|
const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`);
|
|
1087
1620
|
if (statusAfter !== body) {
|
|
@@ -1132,13 +1665,49 @@ function milestoneCompleteCore(content, intent, deps) {
|
|
|
1132
1665
|
* Apply a `patch` transition to STATE.md content.
|
|
1133
1666
|
*
|
|
1134
1667
|
* Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each
|
|
1135
|
-
* caller-supplied `{field: value}` pair
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1668
|
+
* caller-supplied `{field: value}` pair, resolved BODY-FIRST:
|
|
1669
|
+
*
|
|
1670
|
+
* - A key that resolves against the STRIPPED body (via `stateReplaceField`,
|
|
1671
|
+
* case-insensitive on the field name) is applied there and reported
|
|
1672
|
+
* `updated` — this is the legitimate, documented case (display-cased body
|
|
1673
|
+
* fields — Status, Current Plan, Phase — which are never frontmatter
|
|
1674
|
+
* keys). It wins deterministically even when the same key also happens to
|
|
1675
|
+
* exist as a parsed frontmatter key (e.g. `status` matches both the
|
|
1676
|
+
* frontmatter key and a `Status:` body line) — frontmatter is inert for
|
|
1677
|
+
* that key.
|
|
1678
|
+
* - Only when the body has no match is the key checked against parsed
|
|
1679
|
+
* frontmatter (determined structurally, never by a naming heuristic), and
|
|
1680
|
+
* routed through the seam: `FIELD_CLASSIFICATION` governs it. A CLASSIFIED
|
|
1681
|
+
* key (has a row, e.g. `current_phase`, `current_phase_name`) is NOT
|
|
1682
|
+
* writable by an arbitrary patch — policy owns it — and is reported
|
|
1683
|
+
* `failed`. An UNCLASSIFIED key (no row, e.g. a custom `risk_level`) is a
|
|
1684
|
+
* pass-through per Phase 1 behavior-table row 19 ("field absent from
|
|
1685
|
+
* FIELD_CLASSIFICATION → untouched pass-through"): it is applied directly
|
|
1686
|
+
* to the frontmatter object before reassembly and reported `updated`.
|
|
1687
|
+
* - A key matching neither the body nor the frontmatter is reported `failed`.
|
|
1688
|
+
*
|
|
1689
|
+
* ADR-3408 §8.3(b): this used to run `stateReplaceField` over the FULL
|
|
1690
|
+
* document (body + frontmatter), which — because `field` is an arbitrary,
|
|
1691
|
+
* caller-supplied string, unlike every other `stateReplaceField` call site in
|
|
1692
|
+
* this file, which passes a fixed Title-Case string literal that can never
|
|
1693
|
+
* collide with a lowercase/snake_case YAML key — let a frontmatter-shaped
|
|
1694
|
+
* patch key (e.g. `status`, `current_phase`) match and rewrite the YAML
|
|
1695
|
+
* frontmatter block directly via `stateReplaceField`'s case-insensitive
|
|
1696
|
+
* `^field:` line pattern, entirely outside `FIELD_CLASSIFICATION` and the
|
|
1697
|
+
* write-seam preservation policy: a second, undeclared writer. The fix is
|
|
1698
|
+
* that a CLASSIFIED frontmatter key no longer writes outside the declared
|
|
1699
|
+
* policy table — not that every frontmatter-shaped key stops working.
|
|
1700
|
+
* `.gsd/phase/refactor-3469-one-write-seam/40-design.md` row 9 requires
|
|
1701
|
+
* frontmatter changes to route through the seam (still work, governed by
|
|
1702
|
+
* FIELD_CLASSIFICATION), not to stop working outright. Body-shaped keys
|
|
1703
|
+
* (`Status`, `Current Plan`, `Phase`, ...) are the LEGITIMATE case and are
|
|
1704
|
+
* unaffected — they were always matched against the body text, and still are.
|
|
1138
1705
|
*
|
|
1139
1706
|
* The curated-field preservation that fixes #1743/#1695 is NOT in this core —
|
|
1140
|
-
* it lives in
|
|
1141
|
-
* `
|
|
1707
|
+
* it lives in the write seam's post-sync delta (table-driven via
|
|
1708
|
+
* `current_phase_name`'s `preserve-when-unchanged` row, ADR-3408 §8.1 —
|
|
1709
|
+
* reclassified from `preserve-always` in #3468 to match its long-standing,
|
|
1710
|
+
* delta-gated behavior).
|
|
1142
1711
|
* `patch` consulting the table "refuses to overwrite" curated fields implicitly:
|
|
1143
1712
|
* when the patch does not change a curated field's body source line, the
|
|
1144
1713
|
* existing frontmatter value wins over the sync re-derivation. The adapter
|
|
@@ -1147,19 +1716,59 @@ function milestoneCompleteCore(content, intent, deps) {
|
|
|
1147
1716
|
* `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
|
|
1148
1717
|
*/
|
|
1149
1718
|
function patchCore(content, intent) {
|
|
1719
|
+
const { existingFm, hasFrontmatter, fmPrefix, unparseableFm } = beginFrontmatterReassembly(content);
|
|
1720
|
+
// #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
|
|
1721
|
+
// literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
|
|
1722
|
+
// Axis 3 single-hop backward scan.
|
|
1723
|
+
let body = stripFrontmatter(content);
|
|
1724
|
+
const fm = { ...existingFm };
|
|
1150
1725
|
const updated = [];
|
|
1151
1726
|
const failed = [];
|
|
1152
|
-
let result = content;
|
|
1153
1727
|
for (const [field, value] of Object.entries(intent.patches)) {
|
|
1154
|
-
|
|
1728
|
+
// Body-first: a key that resolves against a body field is the
|
|
1729
|
+
// legitimate, documented case (display-cased body fields — Status,
|
|
1730
|
+
// Current Plan, Phase — are never frontmatter keys) and wins
|
|
1731
|
+
// deterministically even when the same key also happens to exist as a
|
|
1732
|
+
// frontmatter key (case-insensitively, via stateReplaceField's
|
|
1733
|
+
// `^field:` pattern — e.g. `status` matching both the frontmatter key
|
|
1734
|
+
// and a `Status:` body line). Frontmatter is only consulted when the
|
|
1735
|
+
// body has no match for this key.
|
|
1736
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
|
|
1155
1737
|
if (replaced !== null) {
|
|
1156
|
-
|
|
1738
|
+
body = replaced;
|
|
1157
1739
|
updated.push(field);
|
|
1740
|
+
continue;
|
|
1158
1741
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1742
|
+
if (Object.prototype.hasOwnProperty.call(existingFm, field)) {
|
|
1743
|
+
// Frontmatter-shaped key: route through the seam. A classified field
|
|
1744
|
+
// is policy-owned — a raw patch may not bypass it. An unclassified
|
|
1745
|
+
// field is an untouched pass-through (behavior-table row 19).
|
|
1746
|
+
if (getFieldClassification(field) !== null) {
|
|
1747
|
+
failed.push(field);
|
|
1748
|
+
}
|
|
1749
|
+
else {
|
|
1750
|
+
fm[field] = value;
|
|
1751
|
+
updated.push(field);
|
|
1752
|
+
}
|
|
1753
|
+
continue;
|
|
1161
1754
|
}
|
|
1162
|
-
|
|
1755
|
+
failed.push(field);
|
|
1756
|
+
}
|
|
1757
|
+
if (updated.length === 0) {
|
|
1758
|
+
// No field matched — return `content` VERBATIM (mirrors `updateCore`'s
|
|
1759
|
+
// null-result branch): reassembling via stripFrontmatter/
|
|
1760
|
+
// reconstructFrontmatter even when nothing changed can round-trip the
|
|
1761
|
+
// frontmatter block to different bytes than the original (key order,
|
|
1762
|
+
// formatting), which would falsely defeat `readModifyWriteStateMd`'s
|
|
1763
|
+
// #948 no-op write guard for every patch that updates nothing, not just
|
|
1764
|
+
// a frontmatter-shaped one.
|
|
1765
|
+
return { content, updated, data: { updated, failed } };
|
|
1766
|
+
}
|
|
1767
|
+
const result = hasFrontmatter
|
|
1768
|
+
? `---\n${reconstructFrontmatter(fm)}\n---\n\n${body}`
|
|
1769
|
+
: unparseableFm
|
|
1770
|
+
? `${fmPrefix}${body}`
|
|
1771
|
+
: body;
|
|
1163
1772
|
return { content: result, updated, data: { updated, failed } };
|
|
1164
1773
|
}
|
|
1165
1774
|
// ----------------------------------------------------------------------------
|
|
@@ -1174,16 +1783,77 @@ function patchCore(content, intent) {
|
|
|
1174
1783
|
* Mirrors the pre-migration body-strip/reassemble contract.
|
|
1175
1784
|
*/
|
|
1176
1785
|
function updateCore(content, intent) {
|
|
1177
|
-
const existingFm =
|
|
1178
|
-
|
|
1786
|
+
const { existingFm, hasFrontmatter, reassemble } = beginFrontmatterReassembly(content);
|
|
1787
|
+
// #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
|
|
1788
|
+
// literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
|
|
1789
|
+
// Axis 3 single-hop backward scan.
|
|
1179
1790
|
const body = stripFrontmatter(content);
|
|
1180
|
-
|
|
1791
|
+
// #3699 review: session-scoped fields are written through the session-scoped
|
|
1792
|
+
// writer. A whole-body `stateReplaceField` matches the FIRST occurrence
|
|
1793
|
+
// anywhere, so with no `Stopped At:` line in `## Session` but a stale one in
|
|
1794
|
+
// `## Session Continuity Archive`, `state update "Stopped At" …` reported
|
|
1795
|
+
// `updated: true` while rewriting the ARCHIVE line and leaving both the session
|
|
1796
|
+
// section and the `stopped_at` frontmatter key untouched — a silent corruption
|
|
1797
|
+
// of a historical record reported as success. #3374 already established this
|
|
1798
|
+
// rule for the other writer; this one had not adopted it.
|
|
1799
|
+
const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
|
|
1800
|
+
let result;
|
|
1801
|
+
if (sessionWriteLabels) {
|
|
1802
|
+
// Replace-only by contract: unchanged content means the field is not in the
|
|
1803
|
+
// session section, which is a miss, not a write.
|
|
1804
|
+
const replaced = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
|
|
1805
|
+
result = replaced === body ? null : replaced;
|
|
1806
|
+
}
|
|
1807
|
+
else {
|
|
1808
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
|
|
1809
|
+
}
|
|
1181
1810
|
if (result === null) {
|
|
1811
|
+
// #3699 case D — the frontmatter fallback.
|
|
1812
|
+
//
|
|
1813
|
+
// Normally frontmatter keys are NOT writable here: they are projections, and
|
|
1814
|
+
// `buildStateFrontmatter` re-derives them from the body on every write, so a
|
|
1815
|
+
// direct frontmatter write would be discarded. But when the body source line
|
|
1816
|
+
// is absent entirely, there is nothing to derive FROM: the key's existing
|
|
1817
|
+
// value survives on `preserve-when-unchanged`, and neither the frontmatter
|
|
1818
|
+
// key nor the body field can be updated by any route. That document is
|
|
1819
|
+
// unrepairable through `state update`, which is the gap this closes.
|
|
1820
|
+
//
|
|
1821
|
+
// Deliberately narrow — all three must hold:
|
|
1822
|
+
// (1) the field is a frontmatter key with a known body source,
|
|
1823
|
+
// (2) NO body source line exists, so the body route is genuinely unavailable
|
|
1824
|
+
// (this is what keeps case A, where the body route works, routing to the
|
|
1825
|
+
// body as before), and
|
|
1826
|
+
// (3) the frontmatter already carries the key, so this updates a value that
|
|
1827
|
+
// is really there rather than inventing one.
|
|
1828
|
+
//
|
|
1829
|
+
// The presence check in (2) is UNSCOPED on purpose, unlike the builder's
|
|
1830
|
+
// `## Session` scoping for stopped_at/paused_at. The asymmetry is the safe
|
|
1831
|
+
// direction: any `Stopped at:` line anywhere in the body — including one in an
|
|
1832
|
+
// archive section — suppresses the fallback, so this never writes frontmatter
|
|
1833
|
+
// while a body line the user could edit still exists.
|
|
1834
|
+
const bodySource = getFrontmatterBodySource(intent.field);
|
|
1835
|
+
const frontmatterCarriesKey = hasFrontmatter && Object.prototype.hasOwnProperty.call(existingFm, intent.field);
|
|
1836
|
+
// The presence check asks the same question the WRITE asks, in the same
|
|
1837
|
+
// scope. An earlier cut checked the whole body on the reasoning that any
|
|
1838
|
+
// editable line should suppress the repair — but a line the reader never
|
|
1839
|
+
// reads is not a source, and suppressing on it left the document
|
|
1840
|
+
// unrepairable while pointing the user at a command that would rewrite the
|
|
1841
|
+
// wrong line. Same scope for read, write and probe, or they disagree.
|
|
1842
|
+
const sessionProbeLabels = sessionLabelsForKey(intent.field);
|
|
1843
|
+
const bodySourceExists = sessionProbeLabels
|
|
1844
|
+
? sessionSourceExists(body, sessionProbeLabels)
|
|
1845
|
+
: (bodySource ?? []).some((f) => (0, state_document_cjs_1.stateExtractField)(body, f) !== null);
|
|
1846
|
+
if (bodySource && frontmatterCarriesKey && !bodySourceExists) {
|
|
1847
|
+
const nextFm = { ...existingFm, [intent.field]: intent.value };
|
|
1848
|
+
return {
|
|
1849
|
+
content: `---\n${reconstructFrontmatter(nextFm)}\n---\n\n${body}`,
|
|
1850
|
+
updated: [intent.field],
|
|
1851
|
+
data: { updated: true, wroteFrontmatter: true },
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1182
1854
|
return { content, updated: [], data: { updated: false } };
|
|
1183
1855
|
}
|
|
1184
|
-
const reassembled =
|
|
1185
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`
|
|
1186
|
-
: result;
|
|
1856
|
+
const reassembled = reassemble(result);
|
|
1187
1857
|
return { content: reassembled, updated: [intent.field], data: { updated: true } };
|
|
1188
1858
|
}
|
|
1189
1859
|
// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
|