@opengsd/gsd-core 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +78 -42
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +0 -1
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +3 -1
- package/agents/gsd-plan-checker.md +91 -112
- package/agents/gsd-planner.md +20 -4
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +82 -7
- package/agents/gsd-ui-researcher.md +70 -3
- package/agents/gsd-verifier.md +24 -2
- package/bin/install.js +847 -200
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/quick.md +8 -4
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +761 -100
- package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
- package/gsd-core/bin/lib/api-coverage.cjs +30 -9
- package/gsd-core/bin/lib/artifacts.cjs +2 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
- package/gsd-core/bin/lib/audit.cjs +163 -41
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +785 -144
- package/gsd-core/bin/lib/capability-state.cjs +25 -4
- package/gsd-core/bin/lib/capability-validator.cjs +321 -18
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +229 -6
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +877 -54
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +121 -29
- package/gsd-core/bin/lib/config.cjs +92 -2
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +118 -14
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +23 -2
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +975 -326
- package/gsd-core/bin/lib/gap-checker.cjs +41 -8
- package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
- package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
- package/gsd-core/bin/lib/host-integration.cjs +96 -11
- package/gsd-core/bin/lib/init-command-router.cjs +132 -21
- package/gsd-core/bin/lib/init.cjs +252 -56
- package/gsd-core/bin/lib/install-engine.cjs +252 -15
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
- package/gsd-core/bin/lib/install-profiles.cjs +100 -18
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +195 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +175 -4
- package/gsd-core/bin/lib/milestone.cjs +112 -7
- package/gsd-core/bin/lib/model-catalog.cjs +177 -19
- package/gsd-core/bin/lib/model-resolver.cjs +10 -28
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +321 -13
- package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
- package/gsd-core/bin/lib/phase-locator.cjs +138 -17
- package/gsd-core/bin/lib/phase.cjs +1175 -115
- package/gsd-core/bin/lib/plan-document.cjs +273 -0
- package/gsd-core/bin/lib/plan-scan.cjs +13 -2
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
- package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
- package/gsd-core/bin/lib/probe-core.cjs +4 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
- package/gsd-core/bin/lib/roadmap.cjs +248 -64
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
- package/gsd-core/bin/lib/smart-entry.cjs +19 -31
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +47 -18
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +216 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
- package/gsd-core/bin/lib/state-transition.cjs +850 -145
- package/gsd-core/bin/lib/state.cjs +1629 -287
- package/gsd-core/bin/lib/surface.cjs +33 -10
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
- package/gsd-core/bin/lib/uat.cjs +2542 -387
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
- package/gsd-core/bin/lib/unusable-input.cjs +13 -0
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/vendor/README.md +43 -5
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -13
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +441 -56
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +6 -5
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +37 -19
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/edge-probe.md +17 -5
- package/gsd-core/references/execute-mvp-tdd.md +18 -18
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +3 -3
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +22 -0
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +76 -3
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +39 -9
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/tdd.md +15 -12
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +2 -2
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +11 -11
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/phase-prompt.md +7 -0
- package/gsd-core/templates/state.md +7 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +3 -1
- package/gsd-core/workflows/add-phase.md +5 -3
- package/gsd-core/workflows/add-tests.md +4 -9
- package/gsd-core/workflows/add-todo.md +2 -2
- package/gsd-core/workflows/ai-integration-phase.md +5 -10
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +14 -3
- package/gsd-core/workflows/audit-milestone.md +11 -9
- package/gsd-core/workflows/audit-uat.md +19 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +12 -26
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/cleanup.md +3 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
- package/gsd-core/workflows/code-review-fix.md +3 -1
- package/gsd-core/workflows/code-review.md +192 -69
- package/gsd-core/workflows/complete-milestone.md +28 -14
- package/gsd-core/workflows/debug.md +6 -4
- package/gsd-core/workflows/diagnose-issues.md +17 -7
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +46 -19
- package/gsd-core/workflows/docs-update.md +6 -5
- package/gsd-core/workflows/edit-phase.md +3 -1
- package/gsd-core/workflows/eval-review.md +5 -10
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +68 -66
- package/gsd-core/workflows/execute-plan.md +25 -20
- package/gsd-core/workflows/explore.md +3 -1
- package/gsd-core/workflows/extract-learnings.md +3 -1
- package/gsd-core/workflows/fast.md +8 -2
- package/gsd-core/workflows/forensics.md +3 -1
- package/gsd-core/workflows/graduation.md +6 -6
- package/gsd-core/workflows/health.md +4 -7
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +17 -14
- package/gsd-core/workflows/inbox.md +5 -6
- package/gsd-core/workflows/ingest-docs.md +45 -12
- package/gsd-core/workflows/insert-phase.md +7 -5
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +7 -3
- package/gsd-core/workflows/list-workspaces.md +3 -1
- package/gsd-core/workflows/manager.md +15 -26
- package/gsd-core/workflows/map-codebase.md +3 -1
- package/gsd-core/workflows/milestone-summary.md +3 -1
- package/gsd-core/workflows/mvp-phase.md +3 -3
- package/gsd-core/workflows/new-milestone.md +10 -22
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +17 -29
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +4 -2
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +20 -5
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
- package/gsd-core/workflows/plan-phase.md +251 -54
- package/gsd-core/workflows/plan-review-convergence.md +148 -19
- package/gsd-core/workflows/plant-seed.md +3 -3
- package/gsd-core/workflows/pr-branch.md +195 -51
- package/gsd-core/workflows/profile-user.md +17 -15
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +52 -15
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +33 -32
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +6 -4
- package/gsd-core/workflows/remove-workspace.md +3 -3
- package/gsd-core/workflows/resume-project.md +14 -14
- package/gsd-core/workflows/review.md +404 -21
- package/gsd-core/workflows/scan.md +3 -1
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +9 -9
- package/gsd-core/workflows/settings-integrations.md +66 -32
- package/gsd-core/workflows/settings.md +4 -6
- package/gsd-core/workflows/ship.md +22 -16
- package/gsd-core/workflows/sketch-wrap-up.md +13 -17
- package/gsd-core/workflows/sketch.md +13 -19
- package/gsd-core/workflows/smart-entry.md +4 -6
- package/gsd-core/workflows/spec-phase.md +31 -4
- package/gsd-core/workflows/spike-wrap-up.md +9 -11
- package/gsd-core/workflows/spike.md +21 -32
- package/gsd-core/workflows/stats.md +4 -2
- package/gsd-core/workflows/sync-skills.md +13 -5
- package/gsd-core/workflows/thread.md +13 -7
- package/gsd-core/workflows/transition.md +7 -5
- package/gsd-core/workflows/ui-phase.md +36 -21
- package/gsd-core/workflows/ui-review.md +7 -11
- package/gsd-core/workflows/ultraplan-phase.md +7 -13
- package/gsd-core/workflows/undo.md +9 -17
- package/gsd-core/workflows/update.md +47 -48
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +106 -21
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +302 -22
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +77 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +46 -12
- package/hooks/dist/gsd-read-guard.js +18 -7
- package/hooks/dist/gsd-read-injection-scanner.js +22 -13
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +222 -29
- package/hooks/dist/gsd-validate-commit.sh +523 -12
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +36 -17
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +4 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +302 -22
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +3 -1
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +28 -23
- package/hooks/gsd-cursor-subagent-stop.js +3 -1
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +77 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +46 -12
- package/hooks/gsd-read-guard.js +18 -7
- package/hooks/gsd-read-injection-scanner.js +22 -13
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +222 -29
- package/hooks/gsd-validate-commit.sh +523 -12
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +36 -17
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/hooks.json +6 -0
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +4 -0
- package/package.json +14 -9
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +12 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +52 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +406 -0
- package/scripts/gen-capability-registry.cjs +8 -6
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-loop-host-contract.cjs +189 -4
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +32 -2
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +45 -14
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-removed-but-needed.cjs +184 -16
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +124 -4
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +51 -64
- package/scripts/lint-vendored-deps.cjs +208 -35
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +83 -14
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +22 -3
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -109
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/state-write-path-drift-baseline.json +0 -19
|
@@ -15,8 +15,16 @@
|
|
|
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.formatProgressMachineSegment = formatProgressMachineSegment;
|
|
20
|
+
exports.stateReplaceProgressPercent = stateReplaceProgressPercent;
|
|
21
|
+
exports.beginFrontmatterReassembly = beginFrontmatterReassembly;
|
|
22
|
+
exports.getFrontmatterBodySource = getFrontmatterBodySource;
|
|
23
|
+
exports.frontmatterKeyForBodyField = frontmatterKeyForBodyField;
|
|
19
24
|
exports.getFieldClassification = getFieldClassification;
|
|
25
|
+
exports.getPreserveWhenUnchangedFields = getPreserveWhenUnchangedFields;
|
|
26
|
+
exports.openStateTransaction = openStateTransaction;
|
|
27
|
+
exports.rebuildStateTransaction = rebuildStateTransaction;
|
|
20
28
|
exports.applyPreserveWhenUnchanged = applyPreserveWhenUnchanged;
|
|
21
29
|
exports.applyStatePreservation = applyStatePreservation;
|
|
22
30
|
exports.transitionCore = transitionCore;
|
|
@@ -28,7 +36,90 @@ const state_document_cjs_2 = require("./state-document.cjs");
|
|
|
28
36
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
29
37
|
const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
|
|
30
38
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
31
|
-
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
const stateMdSchemaMod = require("./state-md-schema.cjs");
|
|
41
|
+
const { STATE_FIELD_SCHEMA } = stateMdSchemaMod;
|
|
42
|
+
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatter;
|
|
43
|
+
function formatProgressMachineSegment(percent) {
|
|
44
|
+
// ADR-3180 Decision 7: rounding and the 100 ceiling belong to the
|
|
45
|
+
// completion-ratio kernel. The floor is added here because this helper is
|
|
46
|
+
// also fed persisted frontmatter values (hand-editable, unlike the
|
|
47
|
+
// count-shaped entries into that kernel), and `'░'.repeat` throws on a
|
|
48
|
+
// negative count. Bar and printed percent use the clamped value so the two
|
|
49
|
+
// halves of the segment can never disagree.
|
|
50
|
+
const clamped = Math.max(0, (0, phase_lifecycle_cjs_1.clampPercentFromFraction)(percent / 100));
|
|
51
|
+
const filled = Math.round(clamped / 10);
|
|
52
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}] ${clamped}%`;
|
|
53
|
+
}
|
|
54
|
+
// Consumers (a future STATE.md writer that bypasses all three reintroduces the
|
|
55
|
+
// #4213 divergence class): `cmdStateUpdateProgress` and `syncCore`'s progress
|
|
56
|
+
// intent (both in this module) plus the post-sync body reconciliation in
|
|
57
|
+
// `applyPostSyncPreservation` (src/state.cts). `cmdStateSync` never reaches
|
|
58
|
+
// that reconciliation — ADR-3408 §8.3: `state sync` lets the body win, so
|
|
59
|
+
// preservation must NOT run — which is why its correctness comes from
|
|
60
|
+
// `syncCore`'s call here.
|
|
61
|
+
function stateReplaceProgressPercent(content, percent) {
|
|
62
|
+
const body = stripFrontmatter(content);
|
|
63
|
+
// #2177: bold `**Progress:**` anywhere in the body wins outright; the plain
|
|
64
|
+
// `^Progress:` form is the fallback only when no bold line exists, so an
|
|
65
|
+
// earlier free-text line starting with `Progress:` cannot capture the
|
|
66
|
+
// rewrite ahead of the real status line.
|
|
67
|
+
const boldProgressPattern = /(\*\*Progress:\*\*[ \t]*)([^\r\n]*)/i;
|
|
68
|
+
const plainProgressPattern = /^(Progress:[ \t]*)([^\r\n]*)/im;
|
|
69
|
+
const pattern = boldProgressPattern.test(body)
|
|
70
|
+
? boldProgressPattern
|
|
71
|
+
: plainProgressPattern.test(body)
|
|
72
|
+
? plainProgressPattern
|
|
73
|
+
: null;
|
|
74
|
+
if (!pattern)
|
|
75
|
+
return null;
|
|
76
|
+
const machineSegment = /(?:\[[^\]\r\n]*\][ \t]*)?\d{1,3}%/;
|
|
77
|
+
const progress = formatProgressMachineSegment(percent);
|
|
78
|
+
const updatedBody = body.replace(pattern, (_match, prefix, value) => (`${prefix}${machineSegment.test(value) ? value.replace(machineSegment, progress) : progress}`));
|
|
79
|
+
return content.slice(0, content.length - body.length) + updatedBody;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* ADR-3473 §8.1 (#3881, consequence 2 wiring): does `existingFm` carry the
|
|
83
|
+
* `FRONTMATTER_UNPARSEABLE` marker `extractFrontmatter` sets when a
|
|
84
|
+
* frontmatter-fenced region exists but failed to parse (malformed YAML, or a
|
|
85
|
+
* refused anchor/alias/merge key)? A plain `Object.keys(existingFm).length >
|
|
86
|
+
* 0` check cannot distinguish that case from "no frontmatter block at all" —
|
|
87
|
+
* both parse to `{}` — so every `hasFrontmatter`-gated reassemble below would
|
|
88
|
+
* silently drop the raw frontmatter block on the next write. The marker is a
|
|
89
|
+
* non-enumerable-to-Object.keys Symbol key, so this check is additive and
|
|
90
|
+
* never fires for the genuinely-empty case.
|
|
91
|
+
*/
|
|
92
|
+
function isUnparseableFrontmatter(existingFm) {
|
|
93
|
+
return existingFm[FRONTMATTER_UNPARSEABLE] === true;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* ADR-3473 §8.1 (#3881): the exact bytes `stripFrontmatter` removed from the
|
|
97
|
+
* front of `content` to produce `strippedBody` — i.e. `content`'s raw
|
|
98
|
+
* frontmatter-fenced prefix, verbatim, whether or not it parsed. Reassembling
|
|
99
|
+
* with this prefix (instead of dropping it under `hasFrontmatter === false`)
|
|
100
|
+
* is what preserves an UNPARSEABLE frontmatter block across a write; it is a
|
|
101
|
+
* no-op difference from `content` itself when `strippedBody === content`
|
|
102
|
+
* (nothing was stripped).
|
|
103
|
+
*/
|
|
104
|
+
function rawFrontmatterPrefix(content, strippedBody) {
|
|
105
|
+
return content.slice(0, content.length - strippedBody.length);
|
|
106
|
+
}
|
|
107
|
+
function beginFrontmatterReassembly(content, sourcePath) {
|
|
108
|
+
const existingFm = extractFrontmatter(content, sourcePath);
|
|
109
|
+
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
|
110
|
+
const body = stripFrontmatter(content);
|
|
111
|
+
// ADR-3473 §8.1 (#3881): computed from the ORIGINAL content/body pair, before any caller
|
|
112
|
+
// reassigns `body` further — the captured prefix is always the exact bytes stripped from
|
|
113
|
+
// the ORIGINAL content, regardless of what the caller does with `body` afterward.
|
|
114
|
+
const fmPrefix = rawFrontmatterPrefix(content, body);
|
|
115
|
+
const unparseableFm = isUnparseableFrontmatter(existingFm);
|
|
116
|
+
const reassemble = (b) => hasFrontmatter
|
|
117
|
+
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
118
|
+
: unparseableFm
|
|
119
|
+
? `${fmPrefix}${b}`
|
|
120
|
+
: b;
|
|
121
|
+
return { existingFm, hasFrontmatter, body, fmPrefix, unparseableFm, reassemble };
|
|
122
|
+
}
|
|
32
123
|
// Stop predicate for section-body slicing: a level-2+ heading ends the section.
|
|
33
124
|
const STOP_H2_PLUS = (lv) => lv >= 2;
|
|
34
125
|
/**
|
|
@@ -45,45 +136,150 @@ const STOP_H2_PLUS = (lv) => lv >= 2;
|
|
|
45
136
|
* (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited
|
|
46
137
|
* function). Use `getFieldClassification()` for lookups.
|
|
47
138
|
*/
|
|
48
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
139
|
+
/**
|
|
140
|
+
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
|
141
|
+
* (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical
|
|
142
|
+
* to the pre-#3873 literal table — same 19 keys, same key ORDER (walks
|
|
143
|
+
* `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order
|
|
144
|
+
* comment for why this is the one projection allowed to do that), same
|
|
145
|
+
* per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that
|
|
146
|
+
* key order, `guard`/`mergeStrategy` present only when the schema row carries
|
|
147
|
+
* them — never as an `undefined` own-property), same frozen null-prototype
|
|
148
|
+
* container. Pinned by `tests/state-transition.test.cjs`'s
|
|
149
|
+
* `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is
|
|
150
|
+
* today's literal copied VERBATIM into the test (never re-derived from this
|
|
151
|
+
* schema — see that test's own docstring on why a self-referential parity
|
|
152
|
+
* test proves nothing).
|
|
153
|
+
*/
|
|
154
|
+
exports.FIELD_CLASSIFICATION = Object.freeze(Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => {
|
|
155
|
+
const row = STATE_FIELD_SCHEMA[key];
|
|
156
|
+
const projected = { source: row.source, preservation: row.preservation };
|
|
157
|
+
if (row.guard !== undefined)
|
|
158
|
+
projected.guard = row.guard;
|
|
159
|
+
if (row.mergeStrategy !== undefined)
|
|
160
|
+
projected.mergeStrategy = row.mergeStrategy;
|
|
161
|
+
acc[key] = projected;
|
|
162
|
+
return acc;
|
|
163
|
+
}, Object.create(null)));
|
|
164
|
+
/**
|
|
165
|
+
* Which BODY field feeds each frontmatter key.
|
|
166
|
+
*
|
|
167
|
+
* `FIELD_CLASSIFICATION` above answers "who wins when frontmatter and body
|
|
168
|
+
* disagree"; this answers "and what is the body one called". They are separate
|
|
169
|
+
* questions and this one is display/routing knowledge, not preservation policy,
|
|
170
|
+
* so it does not widen the ADR-3408-governed table.
|
|
171
|
+
*
|
|
172
|
+
* #3699: `state update stopped_at …` reported `Field "stopped_at" not found in
|
|
173
|
+
* STATE.md` — byte-identical to what a genuinely absent field reports. The key
|
|
174
|
+
* IS present; it is a projection of a body field, and the message pointed away
|
|
175
|
+
* from the route that works. Naming the source is what makes the two cases
|
|
176
|
+
* distinguishable.
|
|
177
|
+
*
|
|
178
|
+
* Transcribed from `buildStateFrontmatter` (`state.cts`), which is the real
|
|
179
|
+
* deriver. That makes this a SECOND copy of knowledge that already exists, so it
|
|
180
|
+
* ships with a parity test asserting this key set equals the body-derived key set
|
|
181
|
+
* the builder actually emits (CLAUDE.md → Generative Fix Divergence). Keys the
|
|
182
|
+
* builder derives from disk, an external file, or the clock have no body source
|
|
183
|
+
* and are deliberately ABSENT here rather than mapped to a lie.
|
|
184
|
+
*/
|
|
185
|
+
/**
|
|
186
|
+
* #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA`
|
|
187
|
+
* (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key
|
|
188
|
+
* order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down
|
|
189
|
+
* to the body-sourced keys — the pre-#3873 literal already put `status`
|
|
190
|
+
* before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL`
|
|
191
|
+
* (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables
|
|
192
|
+
* disagreed with each other's order too, and this projection must reproduce
|
|
193
|
+
* ITS table's order specifically. Byte-identical to the pre-#3873 literal —
|
|
194
|
+
* same 8 keys, same order, same frozen null-prototype container with frozen
|
|
195
|
+
* per-key arrays. Pinned by `tests/state-transition.test.cjs`'s
|
|
196
|
+
* `bodySourceProjectionMatchesTodaysTable`.
|
|
197
|
+
*/
|
|
198
|
+
const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([
|
|
199
|
+
'current_phase',
|
|
200
|
+
'current_phase_name',
|
|
201
|
+
'current_plan',
|
|
202
|
+
'status',
|
|
203
|
+
'stopped_at',
|
|
204
|
+
'paused_at',
|
|
205
|
+
'last_activity',
|
|
206
|
+
'last_activity_desc',
|
|
207
|
+
]);
|
|
208
|
+
exports.FRONTMATTER_BODY_SOURCE = Object.freeze(FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => {
|
|
209
|
+
const row = STATE_FIELD_SCHEMA[key];
|
|
210
|
+
acc[key] = Object.freeze([...(row.bodySource ?? [])]);
|
|
211
|
+
return acc;
|
|
212
|
+
}, Object.create(null)));
|
|
213
|
+
/**
|
|
214
|
+
* The frontmatter keys whose body source lives inside `## Session`.
|
|
215
|
+
*
|
|
216
|
+
* #3374 established that these fields must be written where the reader reads
|
|
217
|
+
* them: `buildStateFrontmatter` harvests `Stopped At` / `Paused At` from the
|
|
218
|
+
* session section only, so a whole-body replace "lets a decoy `**Stopped at:**`
|
|
219
|
+
* line in an unrelated (e.g. archive) section absorb the refresh while the
|
|
220
|
+
* harvested session value stays stale" (`stateReplaceFieldInSession`'s own
|
|
221
|
+
* docstring). `updateCore` was still doing the whole-body replace.
|
|
222
|
+
*/
|
|
223
|
+
const SESSION_SCOPED_KEYS = new Set(['stopped_at', 'paused_at']);
|
|
224
|
+
/**
|
|
225
|
+
* The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
|
|
226
|
+
*/
|
|
227
|
+
function sessionLabelsForKey(key) {
|
|
228
|
+
if (!SESSION_SCOPED_KEYS.has(key))
|
|
229
|
+
return null;
|
|
230
|
+
const labels = exports.FRONTMATTER_BODY_SOURCE[key];
|
|
231
|
+
return { primary: labels[0], fallback: labels[1] ?? null };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
|
|
235
|
+
* `Stopped at`, `Paused At`). `null` for anything else.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
|
|
238
|
+
* spellings through one function and used it for the write, which made
|
|
239
|
+
* `state update stopped_at …` write the BODY line through the session writer —
|
|
240
|
+
* silently defeating the "frontmatter keys are not directly writable" contract
|
|
241
|
+
* this whole change exists to state, and reporting `updated: false` while having
|
|
242
|
+
* written. The write may only ever be reached by naming a body field.
|
|
243
|
+
*/
|
|
244
|
+
function sessionLabelsForBodyField(field) {
|
|
245
|
+
const key = frontmatterKeyForBodyField(field);
|
|
246
|
+
return key === null ? null : sessionLabelsForKey(key);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Would a session-scoped write actually land? Asks by attempting the real write
|
|
250
|
+
* with a throwaway value and seeing whether anything moved.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately reuses the writer rather than re-deriving "where is the session
|
|
253
|
+
* section" — a separate scope check could disagree with the writer, and a
|
|
254
|
+
* presence check that disagrees with the write it guards is the whole bug class
|
|
255
|
+
* here. `stateReplaceFieldInSession` is replace-only and pure, so probing costs
|
|
256
|
+
* nothing and the result is discarded.
|
|
257
|
+
*/
|
|
258
|
+
function sessionSourceExists(body, labels) {
|
|
259
|
+
return (0, state_document_cjs_1.stateReplaceFieldInSession)(body, labels.primary, labels.fallback, '\u0000probe') !== body;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Own-property body-source lookup. `null` for a key with no body source (a
|
|
263
|
+
* disk/external/clock-derived key) and for anything not a frontmatter key.
|
|
264
|
+
*/
|
|
265
|
+
function getFrontmatterBodySource(field) {
|
|
266
|
+
if (!Object.prototype.hasOwnProperty.call(exports.FRONTMATTER_BODY_SOURCE, field))
|
|
267
|
+
return null;
|
|
268
|
+
return exports.FRONTMATTER_BODY_SOURCE[field];
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Reverse lookup: the frontmatter key a body field feeds, or `null`.
|
|
272
|
+
* Lets a failed body-field update name the frontmatter key that still carries a
|
|
273
|
+
* value (#3699 case D), instead of reporting a bare absence.
|
|
274
|
+
*/
|
|
275
|
+
function frontmatterKeyForBodyField(bodyField) {
|
|
276
|
+
const wanted = bodyField.trim().toLowerCase();
|
|
277
|
+
for (const key of Object.keys(exports.FRONTMATTER_BODY_SOURCE)) {
|
|
278
|
+
if (exports.FRONTMATTER_BODY_SOURCE[key].some((f) => f.toLowerCase() === wanted))
|
|
279
|
+
return key;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
87
283
|
/**
|
|
88
284
|
* Own-property classification lookup. Returns `null` for unknown fields
|
|
89
285
|
* (including inherited prototype methods like `toString`/`valueOf`).
|
|
@@ -93,6 +289,94 @@ function getFieldClassification(field) {
|
|
|
93
289
|
return null;
|
|
94
290
|
return exports.FIELD_CLASSIFICATION[field];
|
|
95
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* #3836: the single source of truth for "which frontmatter keys carry the
|
|
294
|
+
* `preserve-when-unchanged` policy" — read straight off `FIELD_CLASSIFICATION`
|
|
295
|
+
* rather than re-typed as a hand-maintained literal array at each consumer.
|
|
296
|
+
* `cmdStateJson` (`state.cts`) previously hardcoded a 6-field list that had
|
|
297
|
+
* already drifted from this table by one row (`last_activity_desc`, #3258) —
|
|
298
|
+
* exactly the "second table parallel to the first" shape ADR-3473 exists to
|
|
299
|
+
* remove. `progress`/`milestone`/`milestone_name` carry a different
|
|
300
|
+
* preservation policy (`preserve-always` / `preserve-if-placeholder`) and are
|
|
301
|
+
* naturally excluded by the filter, not by a separate exclusion list.
|
|
302
|
+
*/
|
|
303
|
+
function getPreserveWhenUnchangedFields() {
|
|
304
|
+
return Object.keys(exports.FIELD_CLASSIFICATION).filter((field) => exports.FIELD_CLASSIFICATION[field].preservation === 'preserve-when-unchanged');
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Shared constructor body for `openStateTransaction` / `rebuildStateTransaction`
|
|
308
|
+
* (ADR-3473 §8.6 Decision 2/3). Validates `init.snapshot` and freezes the
|
|
309
|
+
* result so nothing downstream can mutate a transaction after construction
|
|
310
|
+
* (this is what makes the aliasing fix in `applyPreserveAlways`'s clone hold:
|
|
311
|
+
* the snapshot a caller passed in cannot be rewritten out from under it).
|
|
312
|
+
*
|
|
313
|
+
* `{}` and a null-prototype object are BOTH legal snapshots (Decision 2 / row
|
|
314
|
+
* 15 of the behavior table): `extractFrontmatter` returns `{}` for a document
|
|
315
|
+
* with no frontmatter or an unterminated one and never returns null or throws
|
|
316
|
+
* (`src/frontmatter.cts`), so `{}` is the honest snapshot of a real document —
|
|
317
|
+
* and `/gsd-health --repair`, which runs precisely when STATE.md is broken,
|
|
318
|
+
* depends on that staying legal. What is NOT legal is the snapshot being
|
|
319
|
+
* ABSENT (`null`/`undefined`/an array/a non-object): that is the caller
|
|
320
|
+
* forgetting to read the pre-write document at all, a construction failure,
|
|
321
|
+
* not a data question. Conflating "absent" with "empty" would turn the repair
|
|
322
|
+
* path's normal case into a hard throw.
|
|
323
|
+
*/
|
|
324
|
+
function createStateTransaction(kind, init, ctorName) {
|
|
325
|
+
if (init === null || typeof init !== 'object' || Array.isArray(init)) {
|
|
326
|
+
const err = new Error(`${ctorName}: expected an init object, got ${init === null ? 'null' : typeof init}. ` +
|
|
327
|
+
'Per ADR-3473 §8.6 / Decision 2, an absent init is a construction failure, distinct from ' +
|
|
328
|
+
'a legal empty snapshot ({}) — do not "fix" this by tolerating null.');
|
|
329
|
+
err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
|
|
330
|
+
err.constructorName = ctorName;
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
const snapshot = init.snapshot;
|
|
334
|
+
if (snapshot === null || snapshot === undefined || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
335
|
+
const err = new Error(`${ctorName}: init.snapshot is required and must be a non-array object (frontmatter map). ` +
|
|
336
|
+
`Per ADR-3473 §8.6 / Decision 2, an ABSENT snapshot is a construction failure — this is NOT ` +
|
|
337
|
+
'the same as a legal EMPTY snapshot ({}), which every executor accepts and simply finds ' +
|
|
338
|
+
'nothing to restore from (extractFrontmatter returns {} for a document with no parseable ' +
|
|
339
|
+
'frontmatter, and /gsd-health --repair depends on that staying legal). Pass {} explicitly ' +
|
|
340
|
+
'when the document truly has none; do not tolerate null/undefined here.');
|
|
341
|
+
err.code = 'STATE_TRANSACTION_SNAPSHOT_REQUIRED';
|
|
342
|
+
err.constructorName = ctorName;
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
return Object.freeze({
|
|
346
|
+
kind,
|
|
347
|
+
snapshot,
|
|
348
|
+
resync: init.resync === true,
|
|
349
|
+
deriveProgressKeys: init.deriveProgressKeys === true,
|
|
350
|
+
bodyDeltas: init.bodyDeltas,
|
|
351
|
+
explicitProgressField: init.explicitProgressField === true,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* ADR-3473 §8.6's `open()`: the default write-path transaction. Carries the
|
|
356
|
+
* pre-write snapshot and applies preservation (`applyStatePreservation` runs
|
|
357
|
+
* its full dispatch loop against it) — this is every STATE.md write EXCEPT
|
|
358
|
+
* the two sanctioned exceptions below.
|
|
359
|
+
*/
|
|
360
|
+
function openStateTransaction(init) {
|
|
361
|
+
return createStateTransaction('open', init, 'openStateTransaction');
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* ADR-3473 §8.6's `rebuild()`: the TYPED expression of ADR-3408 §8.3's closed
|
|
365
|
+
* list of sanctioned exceptions to the preservation pipeline. Exactly two
|
|
366
|
+
* callers may construct this: `cmdStateSync` (`state sync` re-derives
|
|
367
|
+
* frontmatter FROM the body per #905 — the body is authoritative and
|
|
368
|
+
* preservation would fight it) and `REGENERATE_STATE` (`/gsd-health --repair`'s
|
|
369
|
+
* factory reset — the whole point is to replace what's there). The snapshot
|
|
370
|
+
* is still carried (for §8.7's reporting) but `applyStatePreservation` skips
|
|
371
|
+
* its dispatch loop entirely for a `rebuild` transaction.
|
|
372
|
+
*
|
|
373
|
+
* This list is NOT debt to be paid down later — it is a closed, deliberate
|
|
374
|
+
* set. Adding a third caller is an amendment to ADR-3408 §8.3, not a call site
|
|
375
|
+
* convenience.
|
|
376
|
+
*/
|
|
377
|
+
function rebuildStateTransaction(init) {
|
|
378
|
+
return createStateTransaction('rebuild', init, 'rebuildStateTransaction');
|
|
379
|
+
}
|
|
96
380
|
/**
|
|
97
381
|
* ADR-3408 §8.2: an unenforced `preserve-when-unchanged` row throws. Both
|
|
98
382
|
* ends of this invariant are gsd-core's own source — a declared row the
|
|
@@ -146,7 +430,7 @@ function applyPreserveWhenUnchanged(field, cls, ctx) {
|
|
|
146
430
|
// 2. Only a real, non-whitespace-only curated string is worth restoring
|
|
147
431
|
// (#3468: tightened from `.length > 0` to a trimmed check — a whitespace-
|
|
148
432
|
// only snapshot is not a real curated value).
|
|
149
|
-
const snapshot = ctx.
|
|
433
|
+
const snapshot = ctx.snapshot[field];
|
|
150
434
|
if (typeof snapshot !== 'string' || snapshot.trim().length === 0)
|
|
151
435
|
return;
|
|
152
436
|
// 3. Closed-vocabulary guard: status's 'unknown' sentinel is never restored.
|
|
@@ -163,28 +447,111 @@ function applyPreserveWhenUnchanged(field, cls, ctx) {
|
|
|
163
447
|
ctx.postFm[field] = snapshot;
|
|
164
448
|
ctx.mutated = true;
|
|
165
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* The closed set of `progress` keys whose non-zero value means "a real
|
|
452
|
+
* measurement happened" (ADR-3473 §8.6 / #3756).
|
|
453
|
+
*/
|
|
454
|
+
const PROGRESS_TOTAL_KEYS = ['total_phases', 'total_plans'];
|
|
455
|
+
/**
|
|
456
|
+
* Did this row's derived (or curated) value represent a REAL measurement?
|
|
457
|
+
*
|
|
458
|
+
* For a `progress-ratchet` row (today, only `progress`): an empty
|
|
459
|
+
* milestone-scoped scan is "nothing was measured", not "zero is done"
|
|
460
|
+
* (#3756, and the convention #3233 established — `computeProgressPercent`
|
|
461
|
+
* already returns `null` for an empty denominator). Only the TOTALS decide:
|
|
462
|
+
* `completed_*` being zero is normal for a real project, so it is
|
|
463
|
+
* deliberately excluded from this check. A non-object / absent / negative /
|
|
464
|
+
* non-numeric total is NOT a measurement, so it degrades TOWARD preservation,
|
|
465
|
+
* never toward deletion — `toFiniteNumber` (not a raw `=== 0`/`> 0` test)
|
|
466
|
+
* because frontmatter scalars arrive as STRINGS (`"0"`, not `0`).
|
|
467
|
+
*
|
|
468
|
+
* For any other row (no `progress-ratchet` strategy) the question is
|
|
469
|
+
* meaningless, so it answers `true` and behavior is unchanged — this
|
|
470
|
+
* function is only ever consulted from inside the `preserve-always` /
|
|
471
|
+
* `progress-ratchet` branch below.
|
|
472
|
+
*/
|
|
473
|
+
function scanMeasuredSomething(cls, value) {
|
|
474
|
+
if (cls.mergeStrategy !== 'progress-ratchet')
|
|
475
|
+
return true;
|
|
476
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
477
|
+
return false;
|
|
478
|
+
const rec = value;
|
|
479
|
+
return PROGRESS_TOTAL_KEYS.some((k) => ((0, state_document_cjs_2.toFiniteNumber)(rec[k]) ?? 0) > 0);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Deep-clone a curated value before it re-enters `postFm` (ADR-3473 §8.6,
|
|
483
|
+
* "Defects fixed inline" / aliasing). `structuredClone` is a Node built-in;
|
|
484
|
+
* this repo takes no external deps for it. WHY a clone and not a reference
|
|
485
|
+
* assignment: the transaction's `snapshot` is now the SAME object §8.7's
|
|
486
|
+
* reporting will diff against. Assigning the nested curated object by
|
|
487
|
+
* reference would make `postFm.progress` alias that snapshot, so a later
|
|
488
|
+
* in-place mutation of `postFm` would silently rewrite the snapshot too, and
|
|
489
|
+
* the diff would report "no change" for a field that did change.
|
|
490
|
+
*/
|
|
491
|
+
function cloneCurated(value) {
|
|
492
|
+
return structuredClone(value);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Structural equality for a restored value vs. what `postFm` already held
|
|
496
|
+
* (ADR-3473 §8.6, "Defects fixed inline" / #948 no-op-write family).
|
|
497
|
+
* `JSON.stringify` compare when either side is an object (the `progress`
|
|
498
|
+
* block), `===` otherwise. WHY: `applyPreserveAlways` previously set
|
|
499
|
+
* `ctx.mutated = true` unconditionally at its tail, even when it restored a
|
|
500
|
+
* value identical to what was already there — driving a write that changes
|
|
501
|
+
* nothing but still bumps `last_updated` / restamps `state_head`.
|
|
502
|
+
* `applyPreserveWhenUnchanged` already guards this (its step 5); this brings
|
|
503
|
+
* the two executors into agreement.
|
|
504
|
+
*/
|
|
505
|
+
function preservedValuesEqual(a, b) {
|
|
506
|
+
if (typeof a === 'object' || typeof b === 'object') {
|
|
507
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
508
|
+
}
|
|
509
|
+
return a === b;
|
|
510
|
+
}
|
|
166
511
|
/**
|
|
167
512
|
* Executor for `preservation: 'preserve-always'` (ADR-3408 §8.1). Only
|
|
168
513
|
* `progress` carries this policy today. Preserves #3242/#1446/#2440/#2969
|
|
169
|
-
* semantics byte-for-byte
|
|
170
|
-
* the
|
|
171
|
-
*
|
|
172
|
-
*
|
|
514
|
+
* semantics byte-for-byte on every row the behavior table marks unchanged;
|
|
515
|
+
* ADR-3473 §8.6 fixes the #3756 defect (a resyncing write that measured
|
|
516
|
+
* nothing must not drop a real curated block) plus the two "Defects fixed
|
|
517
|
+
* inline" no-op-write / aliasing bugs.
|
|
173
518
|
*/
|
|
174
519
|
function applyPreserveAlways(field, cls, ctx) {
|
|
175
|
-
|
|
520
|
+
const curated = ctx.snapshot[field];
|
|
521
|
+
if (!curated)
|
|
522
|
+
return;
|
|
523
|
+
const derived = ctx.postFm[field];
|
|
524
|
+
const derivedMeasured = scanMeasuredSomething(cls, derived);
|
|
525
|
+
const curatedMeasured = scanMeasuredSomething(cls, curated);
|
|
526
|
+
// On a resyncing write the fresh derivation is authoritative — UNLESS it
|
|
527
|
+
// measured nothing while the curated block did (#3756), AND the caller did
|
|
528
|
+
// not explicitly name a progress-affecting field this write. The
|
|
529
|
+
// unmeasured-scan guard exists to stop an INCIDENTAL resync (e.g. `state
|
|
530
|
+
// add-decision`, whose `resync` defaults true for reasons that have
|
|
531
|
+
// nothing to do with `progress`) from dropping a real curated block when a
|
|
532
|
+
// milestone-scoped disk scan measures nothing (#3756's archived-milestone
|
|
533
|
+
// case). It must not also block a write the user pointed AT `progress` on
|
|
534
|
+
// purpose: `preserve-always`'s own contract is "never overwrite unless the
|
|
535
|
+
// caller explicitly names this field" (FIELD_CLASSIFICATION doc comment),
|
|
536
|
+
// and `state update Progress` / `state patch Progress=...` are exactly
|
|
537
|
+
// that naming — the resync they trigger must win even when the disk scan
|
|
538
|
+
// it also drives (e.g. because there are no phase dirs at all) reads as
|
|
539
|
+
// "unmeasured" (tests/frontmatter.test.cjs: "state.update \"Progress\"
|
|
540
|
+
// resyncs progress frontmatter from the updated body", pre-existing, #3242).
|
|
541
|
+
if (ctx.resync && (derivedMeasured || !curatedMeasured || ctx.explicitProgressField))
|
|
176
542
|
return;
|
|
177
|
-
|
|
543
|
+
let next;
|
|
544
|
+
if (cls.mergeStrategy === 'progress-ratchet' && ctx.deriveProgressKeys && derived && derivedMeasured) {
|
|
178
545
|
// #2440: total_plans and total_phases always take the derived (post-sync)
|
|
179
546
|
// value even under !resync. This is used by cmdStatePlannedPhase where
|
|
180
547
|
// total_plans must correct upward after plans are added. For body-only
|
|
181
548
|
// writes (state.update/patch without the flag), the wholesale restore
|
|
182
549
|
// below preserves everything as before — the #3242 Bug A protection
|
|
183
550
|
// stays fully in force.
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const merged = { ...
|
|
187
|
-
if (
|
|
551
|
+
const curatedRecord = curated;
|
|
552
|
+
const derivedRecord = (derived ?? {});
|
|
553
|
+
const merged = { ...derivedRecord };
|
|
554
|
+
if (curatedRecord) {
|
|
188
555
|
// #2440: total_plans and total_phases always take the derived value.
|
|
189
556
|
// #2969: completed_plans and completed_phases take the derived value
|
|
190
557
|
// when it is GREATER than the curated value (gap-closure plans that
|
|
@@ -195,11 +562,11 @@ function applyPreserveAlways(field, cls, ctx) {
|
|
|
195
562
|
// disk counts, and a stale curated percent would be incoherent against
|
|
196
563
|
// the ratcheted-up completed counts (e.g. 54/54 at 93%).
|
|
197
564
|
const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
|
|
198
|
-
for (const [key, value] of Object.entries(
|
|
565
|
+
for (const [key, value] of Object.entries(curatedRecord)) {
|
|
199
566
|
if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
|
|
200
567
|
continue;
|
|
201
568
|
if (ratchetUpKeys.has(key)) {
|
|
202
|
-
const derivedNum = typeof
|
|
569
|
+
const derivedNum = typeof derivedRecord[key] === 'number' ? derivedRecord[key] : -Infinity;
|
|
203
570
|
const curatedNum = typeof value === 'number' ? value : -Infinity;
|
|
204
571
|
// Take the derived value only when it ratchets up (strictly
|
|
205
572
|
// greater — #2969's `>` not `>=`); else keep curated.
|
|
@@ -212,11 +579,14 @@ function applyPreserveAlways(field, cls, ctx) {
|
|
|
212
579
|
}
|
|
213
580
|
}
|
|
214
581
|
}
|
|
215
|
-
|
|
582
|
+
next = merged;
|
|
216
583
|
}
|
|
217
584
|
else {
|
|
218
|
-
|
|
585
|
+
next = cloneCurated(curated);
|
|
219
586
|
}
|
|
587
|
+
if (preservedValuesEqual(ctx.postFm[field], next))
|
|
588
|
+
return;
|
|
589
|
+
ctx.postFm[field] = next;
|
|
220
590
|
ctx.mutated = true;
|
|
221
591
|
}
|
|
222
592
|
/**
|
|
@@ -241,7 +611,7 @@ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
|
|
|
241
611
|
&& derivedName.length > 0
|
|
242
612
|
&& derivedName !== MILESTONE_PLACEHOLDER
|
|
243
613
|
&& !/^[\s—–:-]/.test(derivedName);
|
|
244
|
-
const snapshotName = ctx.
|
|
614
|
+
const snapshotName = ctx.snapshot['milestone_name'];
|
|
245
615
|
const snapshotNameIsReal = typeof snapshotName === 'string'
|
|
246
616
|
&& snapshotName.length > 0
|
|
247
617
|
&& snapshotName !== MILESTONE_PLACEHOLDER;
|
|
@@ -251,7 +621,7 @@ function applyPreserveIfPlaceholder(_field, _cls, ctx) {
|
|
|
251
621
|
ctx.postFm['milestone_name'] = snapshotName;
|
|
252
622
|
ctx.mutated = true;
|
|
253
623
|
}
|
|
254
|
-
const snapshotVersion = ctx.
|
|
624
|
+
const snapshotVersion = ctx.snapshot['milestone'];
|
|
255
625
|
if (typeof snapshotVersion === 'string' && snapshotVersion.length > 0 &&
|
|
256
626
|
ctx.postFm['milestone'] !== snapshotVersion) {
|
|
257
627
|
ctx.postFm['milestone'] = snapshotVersion;
|
|
@@ -277,14 +647,22 @@ function applyDerive(_field, _cls, _ctx) {
|
|
|
277
647
|
* any field was restored.
|
|
278
648
|
*/
|
|
279
649
|
function applyStatePreservation(input) {
|
|
650
|
+
const { transaction } = input;
|
|
651
|
+
// A `rebuild()` transaction still carries the snapshot (§8.7's reporting
|
|
652
|
+
// needs it) but must not run preservation at all: `state sync` / `REGENERATE_STATE`
|
|
653
|
+
// exist to let the body / factory-reset win, and restoring curated values
|
|
654
|
+
// over that would re-lock exactly what the command was invoked to replace.
|
|
655
|
+
if (transaction.kind === 'rebuild') {
|
|
656
|
+
return { postFm: input.postFm, mutated: false };
|
|
657
|
+
}
|
|
280
658
|
const ctx = {
|
|
281
|
-
preFm: input.preFm,
|
|
282
659
|
postFm: input.postFm,
|
|
283
|
-
|
|
284
|
-
resync:
|
|
285
|
-
deriveProgressKeys:
|
|
286
|
-
bodyDeltas:
|
|
660
|
+
snapshot: transaction.snapshot,
|
|
661
|
+
resync: transaction.resync,
|
|
662
|
+
deriveProgressKeys: transaction.deriveProgressKeys === true,
|
|
663
|
+
bodyDeltas: transaction.bodyDeltas,
|
|
287
664
|
mutated: false,
|
|
665
|
+
explicitProgressField: transaction.explicitProgressField === true,
|
|
288
666
|
};
|
|
289
667
|
for (const field of Object.keys(exports.FIELD_CLASSIFICATION)) {
|
|
290
668
|
const cls = getFieldClassification(field);
|
|
@@ -386,12 +764,14 @@ function beginPhaseCore(content, intent, deps) {
|
|
|
386
764
|
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
|
387
765
|
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
|
388
766
|
// before the body pipe-table row if full content is passed.
|
|
389
|
-
const
|
|
390
|
-
|
|
767
|
+
const { reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
768
|
+
// #3881 review, finding 5: `body` is deliberately a LITERAL `stripFrontmatter(content)`
|
|
769
|
+
// assignment here rather than the helper's own `body` (which the destructure above skips) —
|
|
770
|
+
// scripts/lint-state-write-path-drift.cjs's Axis 3 backward scan is a single-hop textual
|
|
771
|
+
// pattern match, not real dataflow, and only recognizes `body = stripFrontmatter(...)` written
|
|
772
|
+
// out at the call site. `stripFrontmatter` is pure and idempotent, so computing it here (in
|
|
773
|
+
// addition to the helper's own internal call) changes nothing observable.
|
|
391
774
|
let body = stripFrontmatter(content);
|
|
392
|
-
const reassemble = (b) => hasFrontmatter
|
|
393
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
394
|
-
: b;
|
|
395
775
|
const today = deps.clock.localToday();
|
|
396
776
|
// Consult the field-classification table for the frontmatter keys this
|
|
397
777
|
// transition touches (codex Phase 1 review: "table not consulted by
|
|
@@ -615,6 +995,46 @@ function mutateCurrentPositionResume(body, intent, today, updated) {
|
|
|
615
995
|
}
|
|
616
996
|
return body.slice(0, span.start) + sectionBody + body.slice(span.end);
|
|
617
997
|
}
|
|
998
|
+
const PLAN_SHAPE_N = /^(\d+)(?:\s.*)?$/;
|
|
999
|
+
const PLAN_SHAPE_N_OF_M = /^(\d+)\s+of\s+(\d+)(?:\s.*)?$/;
|
|
1000
|
+
/**
|
|
1001
|
+
* Parse a decimal group into a plan number, or `null` if it is not a value we
|
|
1002
|
+
* are willing to do arithmetic on.
|
|
1003
|
+
*
|
|
1004
|
+
* `parseInt` is deliberately not used on the raw field: it truncates (`"2 of 5"`
|
|
1005
|
+
* -> 2), accepts a sign (`"+2"`), and silently loses precision past
|
|
1006
|
+
* `Number.MAX_SAFE_INTEGER`, where the number we report and the string we write
|
|
1007
|
+
* back stop agreeing. The grammars above already exclude signs and trailing
|
|
1008
|
+
* text, so the only remaining hazard is magnitude.
|
|
1009
|
+
*/
|
|
1010
|
+
function planNumberFrom(digits) {
|
|
1011
|
+
const n = Number(digits);
|
|
1012
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Advance the leading integer of a written plan value, preserving everything
|
|
1016
|
+
* the author wrote around it: the zero-padding width ("04" -> "05") and any
|
|
1017
|
+
* trailing remainder ("2 of 99" -> "3 of 99", and the `\r` of a CRLF file).
|
|
1018
|
+
*
|
|
1019
|
+
* The three parse branches disagree about the field NAME and about whether a
|
|
1020
|
+
* total is carried inline, but they agree completely about this: only the
|
|
1021
|
+
* leading digits are the plan number, and nothing else on the line belongs to
|
|
1022
|
+
* this transition. Writing `String(newPlan)` instead — as the legacy branch
|
|
1023
|
+
* did — discards the author's text on a branch nobody was reading.
|
|
1024
|
+
*
|
|
1025
|
+
* padStart never truncates, so 09 -> 10 widens rather than clipping.
|
|
1026
|
+
*/
|
|
1027
|
+
function bumpLeadingNumber(raw, next) {
|
|
1028
|
+
const digits = /^\d+/.exec(raw);
|
|
1029
|
+
// Total rather than pass-through. `raw.replace(/^\d+/, …)` returns the input
|
|
1030
|
+
// unchanged when there are no leading digits, so `+2` advanced in `data` and
|
|
1031
|
+
// wrote the file untouched — the command reported progress it had not made
|
|
1032
|
+
// and could be re-run forever. The grammars make that unreachable today;
|
|
1033
|
+
// returning null keeps it unreachable if a fourth branch is ever added.
|
|
1034
|
+
if (!digits)
|
|
1035
|
+
return null;
|
|
1036
|
+
return raw.replace(/^\d+/, () => String(next).padStart(digits[0].length, '0'));
|
|
1037
|
+
}
|
|
618
1038
|
/**
|
|
619
1039
|
* Update fields within the ## Current Position section for advancePlan.
|
|
620
1040
|
* Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour:
|
|
@@ -666,19 +1086,80 @@ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastAc
|
|
|
666
1086
|
mutated = true;
|
|
667
1087
|
}
|
|
668
1088
|
}
|
|
669
|
-
if (fields.plan) {
|
|
1089
|
+
if (fields.plan || fields.currentPlan) {
|
|
670
1090
|
// Plan is always replaced — system-derived, not executor-authored.
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1091
|
+
//
|
|
1092
|
+
// Which NAME to write is decided by what the SECTION carries, not by which
|
|
1093
|
+
// header field the value was read from. Mirroring the header was wrong in
|
|
1094
|
+
// both directions: a legacy header with a `Current Plan:` section line left
|
|
1095
|
+
// the section a plan behind, and a hybrid header with a `Plan:` section line
|
|
1096
|
+
// mutated nothing at all. The invariant is per-name — every site spelled
|
|
1097
|
+
// `Current Plan` gets the `Current Plan` value, every `Plan` site gets the
|
|
1098
|
+
// `Plan` value — so both are passed in and each is written where its own
|
|
1099
|
+
// name appears.
|
|
1100
|
+
//
|
|
1101
|
+
// Title-Case LITERALS reach both the regex and stateReplaceField
|
|
1102
|
+
// (ADR-3408 §8.3(b)): a literal cannot collide with a lowercase/snake_case
|
|
1103
|
+
// frontmatter key, whatever the caller passed.
|
|
1104
|
+
//
|
|
1105
|
+
// The replacements go through a replacer FUNCTION, never a replacement
|
|
1106
|
+
// string. `fields.plan` is derived from file content, and `String.replace`
|
|
1107
|
+
// expands `$&`, `` $` `` and `$'` in a replacement string — a STATE.md
|
|
1108
|
+
// carrying `Current Plan: 04 of 06 $&` would splice part of itself into the
|
|
1109
|
+
// document. `stateReplaceField` already uses a function for this reason;
|
|
1110
|
+
// these arms now agree with it.
|
|
1111
|
+
// Each name is written INDEPENDENTLY, and each falls back on its own.
|
|
1112
|
+
//
|
|
1113
|
+
// Two defects lived in the previous shape, both of which produced the
|
|
1114
|
+
// split-brain document this arm exists to prevent:
|
|
1115
|
+
//
|
|
1116
|
+
// - The fallback was guarded by `!mutated`, and `mutated` is FUNCTION-wide
|
|
1117
|
+
// — already set by the `phase`/`status`/`lastActivity` arms above, which
|
|
1118
|
+
// `advancePlanCore` always populates. A section spelled `**Current
|
|
1119
|
+
// Plan:**` (bold) or as a pipe-table row therefore skipped its fallback
|
|
1120
|
+
// because an UNRELATED field had been refreshed, and the section stayed a
|
|
1121
|
+
// plan behind the header.
|
|
1122
|
+
// - The fallback then picked ONE name by ternary. In the legacy shape both
|
|
1123
|
+
// values are populated, so it always chose `Current Plan` and a
|
|
1124
|
+
// `**Plan:**` section line — which base did write — got nothing.
|
|
1125
|
+
//
|
|
1126
|
+
// `planWritten` is local, so nothing outside this arm can satisfy its guard.
|
|
1127
|
+
let planWritten = false;
|
|
1128
|
+
const writePlanField = (name, value) => {
|
|
1129
|
+
if (!value)
|
|
1130
|
+
return;
|
|
1131
|
+
// Plain `Name:` line first. Title-Case LITERALS reach both the regex and
|
|
1132
|
+
// stateReplaceField (ADR-3408 §8.3(b)), and the replacement goes through a
|
|
1133
|
+
// replacer FUNCTION so a `$&` / `` $` `` / `$'` in the author's text is not
|
|
1134
|
+
// expanded into the document.
|
|
1135
|
+
if (name === 'Current Plan') {
|
|
1136
|
+
if (/^Current Plan:/m.test(sectionBody)) {
|
|
1137
|
+
sectionBody = sectionBody.replace(/^Current Plan:.*$/m, () => `Current Plan: ${value}`);
|
|
1138
|
+
planWritten = true;
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Current Plan', value);
|
|
1142
|
+
if (replaced !== null) {
|
|
1143
|
+
sectionBody = replaced;
|
|
1144
|
+
planWritten = true;
|
|
1145
|
+
}
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (/^Plan:/m.test(sectionBody)) {
|
|
1149
|
+
sectionBody = sectionBody.replace(/^Plan:.*$/m, () => `Plan: ${value}`);
|
|
1150
|
+
planWritten = true;
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', value);
|
|
677
1154
|
if (replaced !== null) {
|
|
678
1155
|
sectionBody = replaced;
|
|
679
|
-
|
|
1156
|
+
planWritten = true;
|
|
680
1157
|
}
|
|
681
|
-
}
|
|
1158
|
+
};
|
|
1159
|
+
writePlanField('Current Plan', fields.currentPlan);
|
|
1160
|
+
writePlanField('Plan', fields.plan);
|
|
1161
|
+
if (planWritten)
|
|
1162
|
+
mutated = true;
|
|
682
1163
|
}
|
|
683
1164
|
if (!mutated)
|
|
684
1165
|
return content;
|
|
@@ -690,8 +1171,10 @@ function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastAc
|
|
|
690
1171
|
/**
|
|
691
1172
|
* Apply an `advancePlan` transition to STATE.md content.
|
|
692
1173
|
*
|
|
693
|
-
* Parses Current Plan / Total Plans
|
|
694
|
-
* "Plan: X of Y"
|
|
1174
|
+
* Parses Current Plan / Total Plans in any of three shapes — the legacy
|
|
1175
|
+
* separate fields, the compound "Plan: X of Y", or the hybrid
|
|
1176
|
+
* "Current Plan: X of Y" (legacy name, compound value, no Total Plans
|
|
1177
|
+
* sibling) — increments the plan number, updates body fields
|
|
695
1178
|
* and the ## Current Position section. When currentPlan >= totalPlans,
|
|
696
1179
|
* takes the phase-complete branch (sets Status to "Phase complete — ready
|
|
697
1180
|
* for verification") instead of advancing.
|
|
@@ -708,36 +1191,134 @@ function advancePlanCore(content, deps) {
|
|
|
708
1191
|
// not on the full content. The YAML `status:` key matches `^Status:\s*`
|
|
709
1192
|
// before the body field if full content is passed (codex Phase 2 review:
|
|
710
1193
|
// HIGH blocking finding — same pattern beginPhaseCore already handles).
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
//
|
|
1194
|
+
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1195
|
+
let body = initialBody;
|
|
1196
|
+
// #3807: refuse a Current Position section carrying more than one `Phase:`
|
|
1197
|
+
// entry BEFORE mutating. The plan fields below come from document-wide
|
|
1198
|
+
// first-match extraction, so in a wave-log style section (one entry per
|
|
1199
|
+
// completed wave) the FIRST entry's plan counter silently advanced — in the
|
|
1200
|
+
// reporting incident, a hard-gated final plan 7→8 of 8 — while the entry
|
|
1201
|
+
// the caller meant sat untouched below it, with advanced:true and no
|
|
1202
|
+
// ambiguity signal. advance-plan now refuses before acting. Scoped via the
|
|
1203
|
+
// #2956 canonical locator (stateCurrentPositionSlice — H2 or H3 heading,
|
|
1204
|
+
// the same one cmdStateAdvancePlan's own milestone read uses); NO whole-body
|
|
1205
|
+
// fallback — a legacy-format document with unrelated `Phase:` history lines
|
|
1206
|
+
// elsewhere has no section to disambiguate and must keep its current
|
|
1207
|
+
// behavior rather than be falsely refused.
|
|
1208
|
+
const positionScope = (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
|
|
1209
|
+
if (positionScope !== null) {
|
|
1210
|
+
const phaseCandidates = (positionScope.match(/^Phase:.*$/gm) || []);
|
|
1211
|
+
if (phaseCandidates.length > 1) {
|
|
1212
|
+
return {
|
|
1213
|
+
content,
|
|
1214
|
+
updated: [],
|
|
1215
|
+
data: {
|
|
1216
|
+
error: true,
|
|
1217
|
+
reason: 'ambiguous_position_phase',
|
|
1218
|
+
phase_candidates: phaseCandidates.map((l) => l.trim()),
|
|
1219
|
+
},
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
// Parse plan number — legacy pair first, then the hybrid, then compound.
|
|
1224
|
+
//
|
|
1225
|
+
// These branches decide ONE thing: which numbers the advance is computed
|
|
1226
|
+
// from. They deliberately do not record which FIELD supplied them, because
|
|
1227
|
+
// the write path no longer asks — every spelling is written back from its own
|
|
1228
|
+
// raw text (#3791 review round 6, B1/M1). An earlier revision tracked a
|
|
1229
|
+
// `planSourceField`/`planRawValue` pair here and then wrote the OTHER
|
|
1230
|
+
// spelling from this one's numbers, which is precisely how a field ended up
|
|
1231
|
+
// holding a value nothing had derived for it.
|
|
718
1232
|
const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan');
|
|
719
1233
|
const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase');
|
|
720
1234
|
const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan');
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
738
|
-
if (
|
|
1235
|
+
// Every branch below reads its numbers out of an ANCHORED match's capture
|
|
1236
|
+
// groups. Nothing here calls parseInt on a raw field value, so a value the
|
|
1237
|
+
// grammar does not fully describe cannot half-parse into a plausible number.
|
|
1238
|
+
const legacyNMatch = legacyPlan ? PLAN_SHAPE_N.exec(legacyPlan) : null;
|
|
1239
|
+
const legacyNofMMatch = legacyPlan ? PLAN_SHAPE_N_OF_M.exec(legacyPlan) : null;
|
|
1240
|
+
const totalNMatch = legacyTotal ? PLAN_SHAPE_N.exec(legacyTotal) : null;
|
|
1241
|
+
const planNofMMatch = planField ? PLAN_SHAPE_N_OF_M.exec(planField) : null;
|
|
1242
|
+
const planNMatch = planField ? PLAN_SHAPE_N.exec(planField) : null;
|
|
1243
|
+
let parsedCurrent = null;
|
|
1244
|
+
let parsedTotal = null;
|
|
1245
|
+
if (legacyPlan && legacyTotal && (legacyNMatch || legacyNofMMatch) && totalNMatch) {
|
|
1246
|
+
// Legacy pair wins whenever both fields are present and both are readable,
|
|
1247
|
+
// even if the Current Plan value also carries an "of M" — the explicit
|
|
1248
|
+
// sibling field is the stated intent, so it supplies the total.
|
|
1249
|
+
parsedCurrent = planNumberFrom((legacyNMatch ?? legacyNofMMatch)[1]);
|
|
1250
|
+
parsedTotal = planNumberFrom(totalNMatch[1]);
|
|
1251
|
+
}
|
|
1252
|
+
else if (legacyNofMMatch) {
|
|
1253
|
+
// Hybrid: legacy field name, compound value, no readable Total Plans
|
|
1254
|
+
// sibling. Written by hand (and by agents) often enough to be worth
|
|
1255
|
+
// reading — #3784.
|
|
1256
|
+
parsedCurrent = planNumberFrom(legacyNofMMatch[1]);
|
|
1257
|
+
parsedTotal = planNumberFrom(legacyNofMMatch[2]);
|
|
1258
|
+
}
|
|
1259
|
+
else if (planNofMMatch) {
|
|
1260
|
+
parsedCurrent = planNumberFrom(planNofMMatch[1]);
|
|
1261
|
+
parsedTotal = planNumberFrom(planNofMMatch[2]);
|
|
1262
|
+
}
|
|
1263
|
+
// No branch for a bare `Plan: N` paired with a `Total Plans in Phase: M`
|
|
1264
|
+
// sibling and no `Current Plan` at all (#3791 review round 6, M2). A revision
|
|
1265
|
+
// of this PR accepted it; base did not (its `else if (planField)` arm had no
|
|
1266
|
+
// `of M` match and errored via NaN), and it is out of #3784's scope, which is
|
|
1267
|
+
// the hybrid `Current Plan: N of M`. It cannot be given the schema-row +
|
|
1268
|
+
// forcing-test coupling the other shapes have, either: `Plan` is body-only,
|
|
1269
|
+
// `buildStateFrontmatter` never reads it into frontmatter, so there is no
|
|
1270
|
+
// `current_*` key to hang a row on. An accepted shape with no schema row and
|
|
1271
|
+
// no forcing test is exactly the drift this diff is otherwise built to
|
|
1272
|
+
// prevent, so the shape is refused and named in the error instead.
|
|
1273
|
+
if (parsedCurrent === null || parsedTotal === null) {
|
|
739
1274
|
return { content: reassemble(body), updated: [], data: { error: true } };
|
|
740
1275
|
}
|
|
1276
|
+
const currentPlan = parsedCurrent;
|
|
1277
|
+
const totalPlans = parsedTotal;
|
|
1278
|
+
// Each SPELLING's own plan number, read from its own value (#3791 review
|
|
1279
|
+
// round 6, B1/M1). The parse above picks ONE field to advance FROM; these are
|
|
1280
|
+
// what each field independently claims, and they are the only honest basis
|
|
1281
|
+
// for writing that field back.
|
|
1282
|
+
const legacyOwnCurrent = legacyNMatch || legacyNofMMatch
|
|
1283
|
+
? planNumberFrom((legacyNMatch ?? legacyNofMMatch)[1])
|
|
1284
|
+
: null;
|
|
1285
|
+
const planOwnCurrent = planNofMMatch || planNMatch
|
|
1286
|
+
? planNumberFrom((planNofMMatch ?? planNMatch)[1])
|
|
1287
|
+
: null;
|
|
1288
|
+
// A document carrying BOTH spellings with DIFFERENT plan numbers disagrees
|
|
1289
|
+
// with itself, and no rule here can say which half is right. Refuse.
|
|
1290
|
+
//
|
|
1291
|
+
// This is the #3807 posture one field over: name the conflict, let the caller
|
|
1292
|
+
// resolve it, never pick. The alternative shipped in an earlier revision of
|
|
1293
|
+
// this PR and was the round-6 Blocker — with `Plan` as the parse source, the
|
|
1294
|
+
// write path re-stamped `Current Plan`'s value with the number it had just
|
|
1295
|
+
// derived from `Plan`, so `Current Plan: 7` beside `Plan: 2 of 5` silently
|
|
1296
|
+
// became `Current Plan: 3`. A number with no relationship to the field it was
|
|
1297
|
+
// written into, no error, no diagnostic.
|
|
1298
|
+
//
|
|
1299
|
+
// Placed BEFORE the phase-complete branch deliberately. Guarding only the
|
|
1300
|
+
// normal advance leaves `Current Plan: 7` beside `Plan: 5 of 5` writing a
|
|
1301
|
+
// terminal "Phase complete — ready for verification" into a document whose
|
|
1302
|
+
// two spellings never agreed on where execution was.
|
|
1303
|
+
//
|
|
1304
|
+
// Differing TOTALS are NOT a disagreement about position and are preserved,
|
|
1305
|
+
// not resolved: `Current Plan: 2` / `Total Plans in Phase: 5` beside
|
|
1306
|
+
// `Plan: 2 of 9` advances to `3` and `3 of 9`. Reconciling the two totals
|
|
1307
|
+
// would be this transition inventing an answer to a question nobody asked it.
|
|
1308
|
+
if (legacyOwnCurrent !== null && planOwnCurrent !== null && legacyOwnCurrent !== planOwnCurrent) {
|
|
1309
|
+
return {
|
|
1310
|
+
content: reassemble(body),
|
|
1311
|
+
updated: [],
|
|
1312
|
+
data: {
|
|
1313
|
+
error: true,
|
|
1314
|
+
reason: 'ambiguous_plan_position',
|
|
1315
|
+
plan_candidates: [
|
|
1316
|
+
`Current Plan: ${legacyPlan}`,
|
|
1317
|
+
`Plan: ${planField}`,
|
|
1318
|
+
],
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
741
1322
|
const updated = [];
|
|
742
1323
|
const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
|
743
1324
|
const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
|
@@ -759,24 +1340,98 @@ function advancePlanCore(content, deps) {
|
|
|
759
1340
|
}
|
|
760
1341
|
// Normal advance branch.
|
|
761
1342
|
const newPlan = currentPlan + 1;
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1343
|
+
// The value each SPELLING should carry after the advance. A document may hold
|
|
1344
|
+
// both names (a `Current Plan:` header and a `Plan:` line in the section, or
|
|
1345
|
+
// the reverse), and each has always rendered differently — the legacy field
|
|
1346
|
+
// holds a bare/padded number while the section's `Plan:` line holds the
|
|
1347
|
+
// compound `N of M`.
|
|
1348
|
+
//
|
|
1349
|
+
// Each is advanced from ITS OWN raw text, never from the other's numbers
|
|
1350
|
+
// (#3791 review round 6, B1/M1). `bumpLeadingNumber` replaces only the leading
|
|
1351
|
+
// digits, so the field's zero-padding width, its own ` of M` and any trailing
|
|
1352
|
+
// annotation all survive — which is what the changeset claims, and what the
|
|
1353
|
+
// previous revision did only for whichever field happened to be the parse
|
|
1354
|
+
// source. The other field it re-stamped from numbers that were never its own.
|
|
1355
|
+
const advanceOwn = (raw, own) => {
|
|
1356
|
+
if (raw === null)
|
|
1357
|
+
return undefined;
|
|
1358
|
+
// Present but unreadable (`Plan: TBD`). Leave it exactly as authored: this
|
|
1359
|
+
// transition cannot advance what it cannot read, and writing a derived
|
|
1360
|
+
// number over it is the fabrication B1 was filed for. Stale-and-untouched is
|
|
1361
|
+
// honest; refusing the whole document because an unrelated line is
|
|
1362
|
+
// unreadable would be a narrowing #3784 does not license.
|
|
1363
|
+
if (own === null)
|
|
1364
|
+
return undefined;
|
|
1365
|
+
return bumpLeadingNumber(raw, newPlan) ?? undefined;
|
|
1366
|
+
};
|
|
1367
|
+
// Title-Case LITERALS to stateReplaceField (ADR-3408 §8.3(b)): a literal
|
|
1368
|
+
// cannot collide with a lowercase/snake_case frontmatter key, so it is safe
|
|
1369
|
+
// regardless of how the content argument was derived. `body` here is in fact
|
|
1370
|
+
// `stripFrontmatter(content)`, but the write-path drift guard does not do
|
|
1371
|
+
// dataflow tracking (by design), and satisfying its invariant by construction
|
|
1372
|
+
// is better than asking a reader to re-derive that it holds.
|
|
1373
|
+
// One value per SPELLING, then write both names everywhere they appear.
|
|
1374
|
+
//
|
|
1375
|
+
// `Current Plan` — whatever the author wrote, advanced in place: padding
|
|
1376
|
+
// and any ` of M` preserved.
|
|
1377
|
+
// `Plan` — likewise, so its OWN total survives. `Plan: 2 of 9`
|
|
1378
|
+
// beside a `Total Plans in Phase: 5` advances to
|
|
1379
|
+
// `3 of 9`, not `3 of 5`: the two totals disagreeing is
|
|
1380
|
+
// the document's business, not this transition's to
|
|
1381
|
+
// reconcile.
|
|
1382
|
+
//
|
|
1383
|
+
// The two are deliberately different strings for the legacy shape, which is
|
|
1384
|
+
// why this is a per-name value rather than one shared display value. Writing
|
|
1385
|
+
// only the name the value was PARSED from is what left the other name stale:
|
|
1386
|
+
// a `**Plan:** 2 of 6` header beside a `Current Plan:` line advanced one and
|
|
1387
|
+
// not the other, in whichever direction the precedence happened to fall.
|
|
1388
|
+
//
|
|
1389
|
+
// Each write is a no-op when that name is absent (`stateReplaceField` returns
|
|
1390
|
+
// null), so a document carrying only one spelling is unaffected — and
|
|
1391
|
+
// `undefined` means "present but not advanceable", which is left untouched
|
|
1392
|
+
// rather than overwritten.
|
|
1393
|
+
const currentPlanDisplayValue = advanceOwn(legacyPlan, legacyOwnCurrent);
|
|
1394
|
+
const planDisplayValue = planField === null
|
|
1395
|
+
// No top-level `Plan:` field to advance, but the `## Current Position`
|
|
1396
|
+
// section may still carry a `Plan:` line in a shape `stateExtractField`
|
|
1397
|
+
// does not read. There is no raw text here to preserve, so it gets the
|
|
1398
|
+
// compound rendering that line has always carried.
|
|
1399
|
+
? `${newPlan} of ${totalPlans}`
|
|
1400
|
+
: advanceOwn(planField, planOwnCurrent);
|
|
1401
|
+
if (currentPlanDisplayValue !== undefined) {
|
|
1402
|
+
body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', currentPlanDisplayValue) || body;
|
|
1403
|
+
}
|
|
1404
|
+
// Only touch `Plan` when the document actually declares one. Writing it
|
|
1405
|
+
// unconditionally meant a `stateReplaceField` whose first match could be any
|
|
1406
|
+
// `Plan:` line anywhere in the body — including prose outside
|
|
1407
|
+
// `## Current Position` that was never a field. `planField` is the read of
|
|
1408
|
+
// that same field from the top of this function, so the write is scoped to a
|
|
1409
|
+
// document that has one.
|
|
1410
|
+
if (planField !== null && planDisplayValue !== undefined) {
|
|
765
1411
|
body = (0, state_document_cjs_1.stateReplaceField)(body, 'Plan', planDisplayValue) || body;
|
|
766
1412
|
}
|
|
767
|
-
else {
|
|
768
|
-
planDisplayValue = `${newPlan} of ${totalPlans}`;
|
|
769
|
-
body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', String(newPlan)) || body;
|
|
770
|
-
}
|
|
771
1413
|
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute') || body;
|
|
772
1414
|
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body;
|
|
773
1415
|
body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body;
|
|
774
1416
|
body = mutateCurrentPositionForAdvance(body, {
|
|
775
1417
|
status: 'Ready to execute',
|
|
776
1418
|
lastActivity: today,
|
|
1419
|
+
// Both spellings, each with its own value. The section writes whichever
|
|
1420
|
+
// name it actually carries; passing only the header's name is what left
|
|
1421
|
+
// two sites disagreeing about where execution is.
|
|
777
1422
|
plan: planDisplayValue,
|
|
1423
|
+
currentPlan: currentPlanDisplayValue,
|
|
778
1424
|
}, statusDefaults, lastActivityDefaults);
|
|
779
|
-
|
|
1425
|
+
// Report `Current Plan` only when it actually moved. The write above is
|
|
1426
|
+
// conditional now — a `Current Plan:` that is present but unreadable is left
|
|
1427
|
+
// as authored — so an unconditional push here would report progress this
|
|
1428
|
+
// transition had not made, which is the same sin `bumpLeadingNumber` returns
|
|
1429
|
+
// null to avoid. `reconcileReportedFields` at the `state.cts` caller would
|
|
1430
|
+
// catch it against the persisted bytes, but `transitionCore`'s own `updated`
|
|
1431
|
+
// is consumed directly too and has to be true on its own.
|
|
1432
|
+
if (currentPlanDisplayValue !== undefined)
|
|
1433
|
+
updated.push('Current Plan');
|
|
1434
|
+
updated.push('Status', 'Last Activity', 'Current Position');
|
|
780
1435
|
return {
|
|
781
1436
|
content: reassemble(body),
|
|
782
1437
|
updated,
|
|
@@ -831,12 +1486,8 @@ function completePhaseCore(content, intent, deps) {
|
|
|
831
1486
|
}
|
|
832
1487
|
// #1255: body-field replacements operate on body only (frontmatter stripped),
|
|
833
1488
|
// so the YAML `status:` / `current_phase:` keys cannot shadow the body fields.
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
let body = stripFrontmatter(content);
|
|
837
|
-
const reassemble = (b) => hasFrontmatter
|
|
838
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
839
|
-
: b;
|
|
1489
|
+
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1490
|
+
let body = initialBody;
|
|
840
1491
|
// Current Phase — preserve the existing `of <total>` shape and the phase name
|
|
841
1492
|
// in parens (mirrors phase.cts:1675-1697 byte-for-behaviour).
|
|
842
1493
|
const phaseValue = intent.nextPhaseNum || intent.phaseNum;
|
|
@@ -1010,12 +1661,8 @@ function plannedPhaseCore(content, intent, deps) {
|
|
|
1010
1661
|
}
|
|
1011
1662
|
}
|
|
1012
1663
|
// #1255: body-field replacements operate on body only.
|
|
1013
|
-
const existingFm =
|
|
1014
|
-
|
|
1015
|
-
let body = stripFrontmatter(content);
|
|
1016
|
-
const reassemble = (b) => hasFrontmatter
|
|
1017
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
1018
|
-
: b;
|
|
1664
|
+
const { existingFm, hasFrontmatter, body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1665
|
+
let body = initialBody;
|
|
1019
1666
|
const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status'];
|
|
1020
1667
|
const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
|
|
1021
1668
|
// Status — template-aware (preserve executor-authored values).
|
|
@@ -1258,12 +1905,8 @@ function milestoneCompleteCore(content, intent, deps) {
|
|
|
1258
1905
|
}
|
|
1259
1906
|
}
|
|
1260
1907
|
// #1255: body-field replacements operate on body only.
|
|
1261
|
-
const
|
|
1262
|
-
|
|
1263
|
-
let body = stripFrontmatter(content);
|
|
1264
|
-
const reassemble = (b) => hasFrontmatter
|
|
1265
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
|
1266
|
-
: b;
|
|
1908
|
+
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
|
|
1909
|
+
let body = initialBody;
|
|
1267
1910
|
// Status — `<version> milestone complete`.
|
|
1268
1911
|
const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`);
|
|
1269
1912
|
if (statusAfter !== body) {
|
|
@@ -1365,8 +2008,10 @@ function milestoneCompleteCore(content, intent, deps) {
|
|
|
1365
2008
|
* `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
|
|
1366
2009
|
*/
|
|
1367
2010
|
function patchCore(content, intent) {
|
|
1368
|
-
const existingFm =
|
|
1369
|
-
|
|
2011
|
+
const { existingFm, hasFrontmatter, fmPrefix, unparseableFm } = beginFrontmatterReassembly(content);
|
|
2012
|
+
// #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
|
|
2013
|
+
// literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
|
|
2014
|
+
// Axis 3 single-hop backward scan.
|
|
1370
2015
|
let body = stripFrontmatter(content);
|
|
1371
2016
|
const fm = { ...existingFm };
|
|
1372
2017
|
const updated = [];
|
|
@@ -1413,7 +2058,9 @@ function patchCore(content, intent) {
|
|
|
1413
2058
|
}
|
|
1414
2059
|
const result = hasFrontmatter
|
|
1415
2060
|
? `---\n${reconstructFrontmatter(fm)}\n---\n\n${body}`
|
|
1416
|
-
:
|
|
2061
|
+
: unparseableFm
|
|
2062
|
+
? `${fmPrefix}${body}`
|
|
2063
|
+
: body;
|
|
1417
2064
|
return { content: result, updated, data: { updated, failed } };
|
|
1418
2065
|
}
|
|
1419
2066
|
// ----------------------------------------------------------------------------
|
|
@@ -1428,16 +2075,77 @@ function patchCore(content, intent) {
|
|
|
1428
2075
|
* Mirrors the pre-migration body-strip/reassemble contract.
|
|
1429
2076
|
*/
|
|
1430
2077
|
function updateCore(content, intent) {
|
|
1431
|
-
const existingFm =
|
|
1432
|
-
|
|
2078
|
+
const { existingFm, hasFrontmatter, reassemble } = beginFrontmatterReassembly(content);
|
|
2079
|
+
// #3881 review, finding 5: see beginPhaseCore's identical comment above — `body` stays a
|
|
2080
|
+
// literal `stripFrontmatter(content)` assignment here for scripts/lint-state-write-path-drift.cjs's
|
|
2081
|
+
// Axis 3 single-hop backward scan.
|
|
1433
2082
|
const body = stripFrontmatter(content);
|
|
1434
|
-
|
|
2083
|
+
// #3699 review: session-scoped fields are written through the session-scoped
|
|
2084
|
+
// writer. A whole-body `stateReplaceField` matches the FIRST occurrence
|
|
2085
|
+
// anywhere, so with no `Stopped At:` line in `## Session` but a stale one in
|
|
2086
|
+
// `## Session Continuity Archive`, `state update "Stopped At" …` reported
|
|
2087
|
+
// `updated: true` while rewriting the ARCHIVE line and leaving both the session
|
|
2088
|
+
// section and the `stopped_at` frontmatter key untouched — a silent corruption
|
|
2089
|
+
// of a historical record reported as success. #3374 already established this
|
|
2090
|
+
// rule for the other writer; this one had not adopted it.
|
|
2091
|
+
const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
|
|
2092
|
+
let result;
|
|
2093
|
+
if (sessionWriteLabels) {
|
|
2094
|
+
// Replace-only by contract: unchanged content means the field is not in the
|
|
2095
|
+
// session section, which is a miss, not a write.
|
|
2096
|
+
const replaced = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
|
|
2097
|
+
result = replaced === body ? null : replaced;
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
|
|
2101
|
+
}
|
|
1435
2102
|
if (result === null) {
|
|
2103
|
+
// #3699 case D — the frontmatter fallback.
|
|
2104
|
+
//
|
|
2105
|
+
// Normally frontmatter keys are NOT writable here: they are projections, and
|
|
2106
|
+
// `buildStateFrontmatter` re-derives them from the body on every write, so a
|
|
2107
|
+
// direct frontmatter write would be discarded. But when the body source line
|
|
2108
|
+
// is absent entirely, there is nothing to derive FROM: the key's existing
|
|
2109
|
+
// value survives on `preserve-when-unchanged`, and neither the frontmatter
|
|
2110
|
+
// key nor the body field can be updated by any route. That document is
|
|
2111
|
+
// unrepairable through `state update`, which is the gap this closes.
|
|
2112
|
+
//
|
|
2113
|
+
// Deliberately narrow — all three must hold:
|
|
2114
|
+
// (1) the field is a frontmatter key with a known body source,
|
|
2115
|
+
// (2) NO body source line exists, so the body route is genuinely unavailable
|
|
2116
|
+
// (this is what keeps case A, where the body route works, routing to the
|
|
2117
|
+
// body as before), and
|
|
2118
|
+
// (3) the frontmatter already carries the key, so this updates a value that
|
|
2119
|
+
// is really there rather than inventing one.
|
|
2120
|
+
//
|
|
2121
|
+
// The presence check in (2) is UNSCOPED on purpose, unlike the builder's
|
|
2122
|
+
// `## Session` scoping for stopped_at/paused_at. The asymmetry is the safe
|
|
2123
|
+
// direction: any `Stopped at:` line anywhere in the body — including one in an
|
|
2124
|
+
// archive section — suppresses the fallback, so this never writes frontmatter
|
|
2125
|
+
// while a body line the user could edit still exists.
|
|
2126
|
+
const bodySource = getFrontmatterBodySource(intent.field);
|
|
2127
|
+
const frontmatterCarriesKey = hasFrontmatter && Object.prototype.hasOwnProperty.call(existingFm, intent.field);
|
|
2128
|
+
// The presence check asks the same question the WRITE asks, in the same
|
|
2129
|
+
// scope. An earlier cut checked the whole body on the reasoning that any
|
|
2130
|
+
// editable line should suppress the repair — but a line the reader never
|
|
2131
|
+
// reads is not a source, and suppressing on it left the document
|
|
2132
|
+
// unrepairable while pointing the user at a command that would rewrite the
|
|
2133
|
+
// wrong line. Same scope for read, write and probe, or they disagree.
|
|
2134
|
+
const sessionProbeLabels = sessionLabelsForKey(intent.field);
|
|
2135
|
+
const bodySourceExists = sessionProbeLabels
|
|
2136
|
+
? sessionSourceExists(body, sessionProbeLabels)
|
|
2137
|
+
: (bodySource ?? []).some((f) => (0, state_document_cjs_1.stateExtractField)(body, f) !== null);
|
|
2138
|
+
if (bodySource && frontmatterCarriesKey && !bodySourceExists) {
|
|
2139
|
+
const nextFm = { ...existingFm, [intent.field]: intent.value };
|
|
2140
|
+
return {
|
|
2141
|
+
content: `---\n${reconstructFrontmatter(nextFm)}\n---\n\n${body}`,
|
|
2142
|
+
updated: [intent.field],
|
|
2143
|
+
data: { updated: true, wroteFrontmatter: true },
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
1436
2146
|
return { content, updated: [], data: { updated: false } };
|
|
1437
2147
|
}
|
|
1438
|
-
const reassembled =
|
|
1439
|
-
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`
|
|
1440
|
-
: result;
|
|
2148
|
+
const reassembled = reassemble(result);
|
|
1441
2149
|
return { content: reassembled, updated: [intent.field], data: { updated: true } };
|
|
1442
2150
|
}
|
|
1443
2151
|
// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
|
|
@@ -1588,13 +2296,10 @@ function syncCore(content, intent, deps) {
|
|
|
1588
2296
|
if (currentProgress) {
|
|
1589
2297
|
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
|
1590
2298
|
if (currentPercent !== intent.percent) {
|
|
1591
|
-
const
|
|
1592
|
-
const filled = Math.round((intent.percent / 100) * barWidth);
|
|
1593
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
1594
|
-
const progressStr = `[${bar}] ${intent.percent}%`;
|
|
1595
|
-
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
|
1596
|
-
const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
|
|
2299
|
+
const result = stateReplaceProgressPercent(modified, intent.percent);
|
|
1597
2300
|
if (result) {
|
|
2301
|
+
const progressStr = formatProgressMachineSegment(intent.percent);
|
|
2302
|
+
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
|
1598
2303
|
modified = result;
|
|
1599
2304
|
updated.push('Progress');
|
|
1600
2305
|
}
|